Secondary External API Adapter¶
How to add an external API client adapter to a HexaGo-generated project.
Anywhere you see
github.com/padiazg/chuck-norrismodule replace it with yours.
Overview¶
External API adapters (secondary/outbound) connect your application to third-party services like data providers, payment gateways, or messaging platforms.
Initialize Project¶
Create the project with all required features:
$ hexago init chuck-norris \
--project-type service \
--explicit-ports \
--module github.com/padiazg/chuck-norris
π Project Configuration:
Name: chuck-norris
Module: github.com/padiazg/chuck-norris
Project Type: service
Adapter Style: primary-secondary
Core Logic: services
Docker: false
Observability: false
Migrations: false
Workers: false
Example Code: false
π Generating project chuck-norris...
π Creating directory structure...
π Generating files...
π¦ Initializing go module...
go: creating new go.mod: module github.com/padiazg/chuck-norris
go: to add module requirements and sums:
go mod tidy
π¦ Adding dependencies...
π§Ή Running go mod tidy...
β¨ Formatting code...
β
Project generated successfully!
π Next steps:
cd chuck-norris
go run main.go run
π Read the README.md for more information about the project structure.
# move to the new project folder
$ cd chuck-norris
Add a domain value object¶
1. Generate the value object¶
$ hexago add domain valueobject Joke \
--fields "id:string,url:string,value:string"
π¦ Adding value object: Joke
Project: chuck-norris
π Creating value object file: internal/core/domain/joke/joke.go
π Creating test file: internal/core/domain/joke/joke_test.go
β
Value object added successfully!
π Next steps:
1. Ensure immutability (no setter methods)
2. Implement validation in constructor
3. Implement Equals method for value comparison
This generates:
internal/core/domain/joke/joke.gointernal/core/domain/joke/joke_test.go
2. Update the value object code¶
You can remove the joke_test.go for now (you can add tests later).
// internal/core/domain/joke/joke.go
package joke
// Joke is a value object representing Joke.
// Value objects are immutable and compared by value, not identity.
type Joke struct {
ID string `json:"id"`
URL string `json:"url"`
Value string `json:"value"`
}
// String returns string representation
func (v Joke) String() string {
return v.Value
}
3. Add the port interface¶
The port interface goes in internal/core/ports/outbound/ β this defines what the external API client must implement.
// internal/core/ports/outbound/joke.go
package outbound
import (
"context"
domain "github.com/padiazg/chuck-norris/internal/core/domain/joke"
)
type JokeProvider interface {
Ping(ctx context.Context) error
GetRandom(ctx context.Context) (*domain.Joke, error)
GetByCategory(ctx context.Context, category string) (*domain.Joke, error)
ListCategories(ctx context.Context) ([]string, error)
Search(ctx context.Context, query string) ([]string, error)
}
Add a secondary adapter¶
A secondary adapter (also called "driven" or "outbound" adapter) implements the port interface we defined earlier (JokeProvider). It contains the actual logic for making HTTP calls to the external Chuck Norris API. The domain and services remain agnostic to how the external API is calledβthey only know about the port interface.
1. Generate the adapter¶
$ hexago add adapter secondary external JokeClient --port JokeProvider
π¦ Adding secondary adapter: JokeClient (external)
Project: chuck-norris
Adapter dir: secondary
π Creating adapter file: internal/adapters/secondary/external/client.go
π Creating test file: internal/adapters/secondary/external/client_test.go
β
Secondary adapter added successfully!
π Next steps:
1. Implement the port interface methods
2. Add database queries or external API calls
3. Wire up dependencies in the DI container
2. Update the adapter code¶
// internal/adapters/secondary/external/client.go
package external
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
domain "github.com/padiazg/chuck-norris/internal/core/domain/joke"
port "github.com/padiazg/chuck-norris/internal/core/ports/outbound"
)
var _ port.JokeProvider = (*Client)(nil)
// Client implements communication with external service
type Client struct {
client *http.Client
baseURL string
}
// ClientConfig is the client configuration
type ClientConfig struct {
Client *http.Client
BaseURL string
}
// Response types for Chuck Norris API
type ChuckResponse struct {
IconURL string `json:"icon_url"`
ID string `json:"id"`
URL string `json:"url"`
Value string `json:"value"`
}
type SearchResponse struct {
Total int `json:"total"`
Result []SearchResult `json:"result"`
}
type SearchResult struct {
Categories []string `json:"categories"`
CreatedAt string `json:"created_at"`
IconURL string `json:"icon_url"`
ID string `json:"id"`
UpdatedAt string `json:"updated_at"`
URL string `json:"url"`
Value string `json:"value"`
}
// NewClient creates a new Client
func NewClient(cfg *ClientConfig) *Client {
if cfg == nil {
cfg = &ClientConfig{}
}
if cfg.BaseURL == "" {
cfg.BaseURL = "https://api.chucknorris.io/jokes"
}
if cfg.Client == nil {
cfg.Client = &http.Client{Timeout: 30 * time.Second}
}
return &Client{
client: cfg.Client,
baseURL: cfg.BaseURL,
}
}
func (c *Client) Ping(ctx context.Context) error {
req, err := http.NewRequestWithContext(ctx, http.MethodHead, c.baseURL+"/categories", nil)
if err != nil {
return fmt.Errorf("joke client ping: create request: %w", err)
}
resp, err := c.client.Do(req)
if resp != nil {
defer resp.Body.Close()
}
if err != nil {
return fmt.Errorf("joke client ping: execute request: %w", err)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("joke client ping: status %s", resp.Status)
}
return nil
}
func (c *Client) GetRandom(ctx context.Context) (*domain.Joke, error) {
url := c.baseURL + "/random"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("joke client get random: create request: %w", err)
}
resp, err := c.client.Do(req)
if resp != nil {
defer resp.Body.Close()
}
if err != nil {
return nil, fmt.Errorf("joke client get random: execute request: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("joke client get random: status %s", resp.Status)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("joke client get random: read body: %w", err)
}
var chuckResp ChuckResponse
if err := json.Unmarshal(body, &chuckResp); err != nil {
return nil, fmt.Errorf("joke client get random: unmarshal: %w", err)
}
return &domain.Joke{
ID: chuckResp.ID,
URL: chuckResp.URL,
Value: chuckResp.Value,
}, nil
}
func (c *Client) GetByCategory(ctx context.Context, category string) (*domain.Joke, error) {
url := fmt.Sprintf("%s/random?category=%s", c.baseURL, category)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("joke client get by category: create request: %w", err)
}
resp, err := c.client.Do(req)
if resp != nil {
defer resp.Body.Close()
}
if err != nil {
return nil, fmt.Errorf("joke client get by category: execute request: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("joke client get by category: status %s", resp.Status)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("joke client get by category: read body: %w", err)
}
var chuckResp ChuckResponse
if err := json.Unmarshal(body, &chuckResp); err != nil {
return nil, fmt.Errorf("joke client get by category: unmarshal: %w", err)
}
return &domain.Joke{
ID: chuckResp.ID,
URL: chuckResp.URL,
Value: chuckResp.Value,
}, nil
}
func (c *Client) ListCategories(ctx context.Context) ([]string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/categories", nil)
if err != nil {
return nil, fmt.Errorf("joke client get categories: create request: %w", err)
}
resp, err := c.client.Do(req)
if resp != nil {
defer resp.Body.Close()
}
if err != nil {
return nil, fmt.Errorf("joke client get categories: execute request: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("joke client get categories: status %s", resp.Status)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("joke client get categories: read body: %w", err)
}
var catResp []string
if err := json.Unmarshal(body, &catResp); err != nil {
return nil, fmt.Errorf("joke client get categories: unmarshal: %w", err)
}
return catResp, nil
}
func (c *Client) Search(ctx context.Context, query string) ([]string, error) {
if query == "" {
return nil, fmt.Errorf("joke client get search: must provide query")
}
url := fmt.Sprintf("%s/search?query=%s", c.baseURL, query)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("joke client get search: create request: %w", err)
}
resp, err := c.client.Do(req)
if resp != nil {
defer resp.Body.Close()
}
if err != nil {
return nil, fmt.Errorf("joke client get search: execute request: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("joke client get search: status %s", resp.Status)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("joke client get search: read body: %w", err)
}
var searchResp SearchResponse
if err := json.Unmarshal(body, &searchResp); err != nil {
return nil, fmt.Errorf("joke client get search: unmarshal: %w", err)
}
var list []string
if searchResp.Total > 0 {
for _, joke := range searchResp.Result {
list = append(list, joke.Value)
}
}
return list, nil
}
Add a service¶
1. Generate the service¶
$ hexago add service Joke
π¦ Adding service: Joke
Project: chuck-norris
Module: github.com/padiazg/chuck-norris
Logic dir: services
π Creating service file: internal/core/services/joke/joke.go
π Creating test file: internal/core/services/joke/joke_test.go
π Updating services aggregator: internal/core/services/services.go
β
Service added successfully!
π Next steps:
1. Implement the business logic in the Execute method
2. Add any required dependencies to the constructor
3. Write tests in the generated test file
2. Update the service code¶
// internal/core/services/joke/joke.go
package joke
import (
"context"
"fmt"
domain "github.com/padiazg/chuck-norris/internal/core/domain/joke"
port "github.com/padiazg/chuck-norris/internal/core/ports/outbound"
)
// JokeService implements Joke logic
type Service struct {
provider port.JokeProvider
}
type Config struct {
Provider port.JokeProvider
}
// NewJokeService creates a new JokeService.
func New(cfg *Config) *Service {
return &Service{provider: cfg.Provider}
}
// Execute runs the service logic.
func (s *Service) Random(ctx context.Context) (*domain.Joke, error) {
return s.provider.GetRandom(ctx)
}
func (s *Service) ByCategory(ctx context.Context, category string) (*domain.Joke, error) {
if category == "" {
return nil, fmt.Errorf("must provide category")
}
return s.provider.GetByCategory(ctx, category)
}
func (s *Service) ListCategories(ctx context.Context) ([]string, error) {
return s.provider.ListCategories(ctx)
}
func (s *Service) Search(ctx context.Context, query string) ([]string, error) {
return s.provider.Search(ctx, query)
}
// internal/core/services/services.go
package services
import (
port "github.com/padiazg/chuck-norris/internal/core/ports/outbound"
svc "github.com/padiazg/chuck-norris/internal/core/services/joke"
)
// Config holds the repository dependencies required to initialise entity-bound services.
type Config struct {
JokeProvider port.JokeProvider
}
// Services aggregates all domain services.
type Services struct {
Joke *svc.Service
}
// New wires all services using the provided repository config.
func New(config *Config) *Services {
return &Services{
Joke: svc.New(&svc.Config{
Provider: config.JokeProvider,
}),
}
}
Wire-up in cmd¶
We won't use the original cmd/run.go command to start a server. Instead, we will implement our own one-time run commands (like a CLI tool). This approach is useful for CLI applications that need to perform specific tasks rather than running a long-lived server.
it's safe to remove
cmd/run.goandinternal/core/services/processor.go
All the commands uses timeout contexts and os signals
1. joke¶
// cmd/joke.go
package cmd
import (
"context"
"fmt"
"os"
"os/signal"
"syscall"
"time"
client "github.com/padiazg/chuck-norris/internal/adapters/secondary/external"
"github.com/padiazg/chuck-norris/internal/core/domain/joke"
services "github.com/padiazg/chuck-norris/internal/core/services"
"github.com/padiazg/chuck-norris/pkg/logger"
"github.com/spf13/cobra"
)
// jokeCmd represents the joke command
var jokeCmd = &cobra.Command{
Use: "joke",
Short: "Random Chuck Norris joke",
Long: `Displays a random Chuck Norris joke`,
RunE: func(cmd *cobra.Command, args []string) error {
cfg := GetConfig()
// Initialize logger from config
log := logger.New(&logger.Config{
Level: cfg.LogLevel,
Format: cfg.LogFormat,
})
// ββ Secondary Adapters ββββββββββββββββββββββββββββββββββββββββββββ
provider := client.NewClient(nil)
// ββ Services (core) βββββββββββββββββββββββββββββββββββββββββββββββ
svc := services.New(&services.Config{
JokeProvider: provider,
})
// Configure context with cancellation for graceful shutdown
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
// Channel to capture OS signals
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM, syscall.SIGINT)
// Channel for processor errors
errChan := make(chan error, 1)
resChan := make(chan string)
category, _ := cmd.Flags().GetString("category")
go func() {
var (
res *joke.Joke
err error
)
if category != "" {
res, err = svc.Joke.ByCategory(ctx, category)
} else {
res, err = svc.Joke.Random(ctx)
}
if err != nil {
errChan <- fmt.Errorf("joke: %w", err)
}
resChan <- res.Value
close(resChan)
}()
// Wait for joke, signal or error
select {
case joke := <-resChan:
fmt.Printf("%s", joke)
case sig := <-sigChan:
log.Info("Received signal %v, initiating graceful shutdown...", sig)
cancel()
case err := <-errChan:
cancel()
return fmt.Errorf("getting joke: %w", err)
case <-ctx.Done():
log.Warn("Timeout, forcing exit")
}
return nil
},
}
func init() {
rootCmd.AddCommand(jokeCmd)
jokeCmd.Flags().StringP("category", "c", "", "Filters the joke for a category")
}
Build and test
make build
$ ./chuck-norris joke
Most people have Microwave ovens. Chuck Norris has a Megawave oven.
$ ./chuck-norris joke --category sport
Chuck Norris plays racquetball with a waffle iron and a bowling ball.
2. categories¶
// cmd/categories.go
package cmd
import (
"context"
"encoding/json"
"fmt"
"os"
"os/signal"
"syscall"
"time"
client "github.com/padiazg/chuck-norris/internal/adapters/secondary/external"
services "github.com/padiazg/chuck-norris/internal/core/services"
"github.com/padiazg/chuck-norris/pkg/logger"
"github.com/spf13/cobra"
)
// categoriesCmd represents the categories command
var categoriesCmd = &cobra.Command{
Use: "categories",
Short: "List",
Long: `List jokes categories`,
RunE: func(cmd *cobra.Command, args []string) error {
cfg := GetConfig()
// Initialize logger from config
log := logger.New(&logger.Config{
Level: cfg.LogLevel,
Format: cfg.LogFormat,
})
// ββ Secondary Adapters ββββββββββββββββββββββββββββββββββββββββββββ
provider := client.NewClient(nil)
// ββ Services (core) βββββββββββββββββββββββββββββββββββββββββββββββ
svc := services.New(&services.Config{
JokeProvider: provider,
})
// Configure context with cancellation for graceful shutdown
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Second)
defer cancel()
// Channel to capture OS signals
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM, syscall.SIGINT)
// Channel for processor errors
errChan := make(chan error, 1)
resChan := make(chan []string)
go func() {
res, err := svc.Joke.ListCategories(ctx)
if err != nil {
errChan <- fmt.Errorf("list: %w", err)
}
resChan <- res
close(resChan)
}()
// Wait for list, signal or error
select {
case list := <-resChan:
bytes, err := json.Marshal(list)
if err != nil {
return fmt.Errorf("marshaling list: %w", err)
}
fmt.Printf("%s", string(bytes))
case sig := <-sigChan:
log.Info("Received signal %v, initiating graceful shutdown...", sig)
cancel()
case err := <-errChan:
cancel()
return fmt.Errorf("getting list: %w", err)
case <-ctx.Done():
log.Warn("Timeout, forcing exit")
}
return nil
},
}
func init() {
rootCmd.AddCommand(categoriesCmd)
}
Build and test
make build
$ ./chuck-norris categories | jq
[
"animal",
"career",
"celebrity",
"dev",
"explicit",
"fashion",
"food",
"history",
"money",
"movie",
"music",
"political",
"religion",
"science",
"sport",
"travel"
]
3. search¶
// cmd/search.go
package cmd
import (
"context"
"encoding/json"
"fmt"
"os"
"os/signal"
"syscall"
"time"
client "github.com/padiazg/chuck-norris/internal/adapters/secondary/external"
services "github.com/padiazg/chuck-norris/internal/core/services"
"github.com/padiazg/chuck-norris/pkg/logger"
"github.com/spf13/cobra"
)
// searchCmd represents the categories command
var searchCmd = &cobra.Command{
Use: "search <query>",
Short: "Search",
Long: `Search jokes by a given string
Returns a list of jokes
Example:
chuck-norris search
`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
query := args[0]
if query == "" {
return fmt.Errorf("search: must provide query")
}
cfg := GetConfig()
// Initialize logger from config
log := logger.New(&logger.Config{
Level: cfg.LogLevel,
Format: cfg.LogFormat,
})
// ββ Secondary Adapters ββββββββββββββββββββββββββββββββββββββββββββ
provider := client.NewClient(nil)
// ββ Services (core) βββββββββββββββββββββββββββββββββββββββββββββββ
svc := services.New(&services.Config{
JokeProvider: provider,
})
// Configure context with cancellation for graceful shutdown
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Second)
defer cancel()
// Channel to capture OS signals
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM, syscall.SIGINT)
// Channel for processor errors
errChan := make(chan error, 1)
resChan := make(chan []string)
go func() {
res, err := svc.Joke.Search(ctx, query)
if err != nil {
errChan <- fmt.Errorf("list: %w", err)
}
resChan <- res
close(resChan)
}()
// Wait for list, signal or error
select {
case list := <-resChan:
bytes, err := json.Marshal(list)
if err != nil {
return fmt.Errorf("marshaling list: %w", err)
}
fmt.Printf("%s", string(bytes))
case sig := <-sigChan:
log.Info("Received signal %v, initiating graceful shutdown...", sig)
cancel()
case err := <-errChan:
cancel()
return fmt.Errorf("getting list: %w", err)
case <-ctx.Done():
log.Warn("Timeout, forcing exit")
}
return nil
},
}
func init() {
rootCmd.AddCommand(searchCmd)
}
Build and test
make build
$ ./chuck-norris search hospital | jq
[
"Chuck Norris built the hospital where he was born.",
"When Chuck Norris was born, the whole hospital cried.",
"Chuck Norris is so hard he jumped from the Eiffel Tower broke both his legs and walked to the hospital",
...
]
Summary¶
This guide demonstrated how to build a CLI application that consumes an external API using hexagonal architecture:
| Step | Layer | Component | HexaGo Command |
|---|---|---|---|
| 1 | - | Project initialization | hexago init |
| 2 | Domain | Value object (Joke) | hexago add domain valueobject |
| 3 | Ports | Port interface (JokeProvider) | Manual in internal/core/ports/outbound/ |
| 4 | Secondary | External API Client | hexago add adapter secondary external |
| 5 | Core | Service implementation | hexago add service |
| 6 | Primary | CLI Commands | Manual in cmd/ |
Key patterns demonstrated:
- Value objects: Immutable domain types with value semantics
- Port interfaces: Define contracts that adapters must implement
- Secondary adapters: Implement outbound ports (external API clients)
- Service layer: Orchestrates domain logic, depends only on ports
- CLI commands: One-time run commands using context and signal handling for graceful shutdown
- Dependency rule: Adapters β Core (never the other way)