background-shape
Hexagonal Architecture Explained for PHP and Go Developers
October 9, 2023 · 7 min read · by Muhammad Amal programming

TL;DR — Hexagonal architecture is one good idea, badly named / The hexagon shape is irrelevant; the inside/outside boundary is everything / It works identically in PHP and Go, the differences are syntax not substance

Alistair Cockburn coined “hexagonal architecture” in 2005. He has since said publicly he wishes he had called it something less geometrically specific, because the hexagon shape implies six sides that mean something, and they do not. The shape just had to be more than four sides to avoid looking like a layered diagram.

The actual idea is short: your application has an inside and an outside. The inside is your business logic. The outside is everything that talks to it — HTTP, databases, message brokers, CLI commands, cron jobs, third-party APIs. The boundary between them is described by interfaces (Cockburn calls them “ports”), and the things on the outside that fulfill those interfaces are “adapters.”

That is the whole thing. The reason it has its own name and not just “interface segregation” is that it organises your folder structure and your reasoning around the boundary, not around technical layers. In the previous post I argued for a three-layer split in PHP; hexagonal is the same shape from a different angle.

Primary and Secondary Ports

The one piece of Cockburn vocabulary worth keeping: ports come in two flavours.

Primary (driving) ports are interfaces the outside world calls on your application. Use case interfaces. The HTTP layer drives the application via these.

Secondary (driven) ports are interfaces your application calls on the outside world. Repository interfaces, mail-sender interfaces, payment-gateway interfaces. The application drives the outside via these.

It matters because the two flavours have different implementations strategies. Primary adapters are usually controllers, console commands, queue listeners — code that translates external input into a use case call. Secondary adapters are usually wrappers around SDKs, ORMs, HTTP clients — code that translates a domain intent into a vendor call.

HTTP request   --> [Primary Adapter] --> [Primary Port] --> Application Core --> [Secondary Port] --> [Secondary Adapter] --> Postgres
   (driving)        (Controller)         (UseCase iface)                          (Repo iface)         (Eloquent Repo)

That sentence is the whole architecture. Everything else is folder layout.

Hexagonal in PHP 8.2

A concrete example: a use case that activates a feature flag for a tenant. We use Laravel Pennant in production for this, but the use case does not know that.

Primary port:

<?php
declare(strict_types=1);

namespace App\Domain\Tenancy\Ports;

interface ActivateFeatureUseCase
{
    public function __invoke(ActivateFeatureInput $input): void;
}

final readonly class ActivateFeatureInput
{
    public function __construct(
        public string $tenantId,
        public string $feature,
        public ?string $activatedBy,
    ) {}
}

Secondary ports:

<?php
declare(strict_types=1);

namespace App\Domain\Tenancy\Ports;

use App\Domain\Tenancy\Tenant;
use App\Domain\Tenancy\TenantId;

interface TenantRepository
{
    public function ofId(TenantId $id): Tenant;
    public function save(Tenant $tenant): void;
}

interface FeatureFlagWriter
{
    public function activate(TenantId $tenant, string $feature): void;
}

interface AuditLog
{
    public function record(string $event, array $context): void;
}

The implementation lives inside the domain or application layer — wherever you put orchestration:

<?php
declare(strict_types=1);

namespace App\Application\Tenancy;

use App\Domain\Tenancy\Ports\{
    ActivateFeatureInput,
    ActivateFeatureUseCase,
    AuditLog,
    FeatureFlagWriter,
    TenantRepository,
};
use App\Domain\Tenancy\TenantId;

final readonly class ActivateFeatureService implements ActivateFeatureUseCase
{
    public function __construct(
        private TenantRepository $tenants,
        private FeatureFlagWriter $flags,
        private AuditLog $audit,
    ) {}

    public function __invoke(ActivateFeatureInput $input): void
    {
        $tenant = $this->tenants->ofId(new TenantId($input->tenantId));
        $tenant->guardCanActivate($input->feature);  // domain invariant

        $this->flags->activate($tenant->id(), $input->feature);
        $this->audit->record('feature.activated', [
            'tenant' => $tenant->id()->toString(),
            'feature' => $input->feature,
            'by' => $input->activatedBy,
        ]);
    }
}

Three secondary ports injected, one primary port implemented. The use case does not know Pennant exists, does not know about Eloquent, does not know how audit logs are stored. All three are wired in AppServiceProvider:

public function register(): void
{
    $this->app->bind(TenantRepository::class, EloquentTenantRepository::class);
    $this->app->bind(FeatureFlagWriter::class, PennantFeatureFlagWriter::class);
    $this->app->bind(AuditLog::class, DatabaseAuditLog::class);
    $this->app->bind(ActivateFeatureUseCase::class, ActivateFeatureService::class);
}

The primary adapter — the controller — is the dumbest piece in the whole stack:

public function activate(
    Request $request,
    string $tenantId,
    ActivateFeatureUseCase $useCase,
): JsonResponse {
    $validated = $request->validate([
        'feature' => 'required|string|max:64',
    ]);

    $useCase(new ActivateFeatureInput(
        tenantId: $tenantId,
        feature: $validated['feature'],
        activatedBy: $request->user()?->getAuthIdentifier(),
    ));

    return response()->json(status: 204);
}

Validation, mapping, dispatching. No business logic. That is what a hexagonal primary adapter looks like.

The Same Thing in Go 1.21

Go does this naturally because interfaces are implicitly satisfied. The structure looks almost identical, with the obvious syntactic differences:

// internal/tenancy/ports.go
package tenancy

import "context"

type ActivateFeatureInput struct {
    TenantID     string
    Feature      string
    ActivatedBy  string
}

type ActivateFeatureUseCase interface {
    Execute(ctx context.Context, in ActivateFeatureInput) error
}

type TenantRepository interface {
    OfID(ctx context.Context, id TenantID) (*Tenant, error)
    Save(ctx context.Context, t *Tenant) error
}

type FeatureFlagWriter interface {
    Activate(ctx context.Context, tenant TenantID, feature string) error
}

type AuditLog interface {
    Record(ctx context.Context, event string, ctxData map[string]any) error
}

The application service:

// internal/tenancy/activate_feature.go
package tenancy

import "context"

type ActivateFeatureService struct {
    Tenants TenantRepository
    Flags   FeatureFlagWriter
    Audit   AuditLog
}

func (s *ActivateFeatureService) Execute(ctx context.Context, in ActivateFeatureInput) error {
    tenant, err := s.Tenants.OfID(ctx, TenantID(in.TenantID))
    if err != nil {
        return err
    }

    if err := tenant.GuardCanActivate(in.Feature); err != nil {
        return err
    }

    if err := s.Flags.Activate(ctx, tenant.ID, in.Feature); err != nil {
        return err
    }

    return s.Audit.Record(ctx, "feature.activated", map[string]any{
        "tenant":  string(tenant.ID),
        "feature": in.Feature,
        "by":      in.ActivatedBy,
    })
}

The HTTP handler (primary adapter, using stdlib net/http):

// internal/http/tenant_handler.go
package httpapi

import (
    "encoding/json"
    "net/http"

    "example.com/app/internal/tenancy"
)

type TenantHandler struct {
    Activate tenancy.ActivateFeatureUseCase
}

func (h *TenantHandler) ActivateFeature(w http.ResponseWriter, r *http.Request) {
    var body struct {
        Feature string `json:"feature"`
    }
    if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
        http.Error(w, "invalid body", http.StatusBadRequest)
        return
    }

    tenantID := r.PathValue("tenantID")  // Go 1.22 path params; on 1.21 use chi/mux
    user, _ := r.Context().Value(currentUserKey{}).(string)

    err := h.Activate.Execute(r.Context(), tenancy.ActivateFeatureInput{
        TenantID:    tenantID,
        Feature:     body.Feature,
        ActivatedBy: user,
    })
    if err != nil {
        writeError(w, err)
        return
    }

    w.WriteHeader(http.StatusNoContent)
}

On Go 1.21 specifically, the stdlib router does not yet have path parameters — those landed in 1.22. We use github.com/go-chi/chi/v5 in production. See the Go 1.21 release notes for the relevant stdlib upgrades; the slog package landing there is also worth a separate post.

Why the Two Look the Same

If you flip between the PHP and Go versions and squint, they are isomorphic. That is the point of hexagonal architecture: it is a structural pattern that does not care about your language. The differences are:

  • PHP uses constructor injection with a container; Go uses struct fields with explicit wiring (or Wire / fx).
  • PHP can lean on __invoke for one-method handlers; Go uses an interface with one method, which is the same idea.
  • Go threads context.Context everywhere; PHP does not have a standard equivalent and uses request-scoped containers.

None of those change the shape of the architecture. The ports/adapters split is the same. The dependency rule is the same. The testability story is the same.

Common Pitfalls

The ways this falls apart in practice:

  • Treating “hexagonal” as a folder name. Putting your code in src/Hexagon/ does not make it hexagonal. The structure is about which way the dependencies point, not where the files live.
  • Ports that leak the adapter. I have seen TenantRepository::findOrFail(string $id): Model. That return type is the adapter leaking through the port. The port should return a domain entity.
  • One port per implementation. You should have fewer ports than adapters, not more. A MailSender port can have MailgunMailSender, SesMailSender, and InMemoryMailSender adapters. If every adapter has its own port, you do not have ports.
  • Hexagonal microservices that share a database. If your “decoupled” services all talk to the same Postgres tables, you do not have decoupled services; you have a distributed monolith with extra HTTP calls. Hexagonal helps within a service; it does not fix bad service boundaries.
  • In-memory adapters that drift. Your test-double InMemoryTenantRepository slowly diverges from PostgresTenantRepository and tests pass while production breaks. Run a contract test suite against both.

The contract-test point is the one most teams skip and most regret. We run the same test file against both adapters using PHPUnit dataProviders / Go subtests. Catches behavioural drift that pure unit tests miss.

Wrapping Up

Hexagonal architecture is a 20-year-old idea that still works because it is about dependency direction, which is language-independent. The PHP and Go versions of the same use case look almost identical because the structural idea is the same. The vocabulary is worth learning even if you end up just calling everything “interfaces and implementations” in casual conversation — when a new engineer joins the team, “primary port” and “secondary adapter” are unambiguous in a way “interface” is not.

Next time I will get into ports and adapters specifically in Go, including how wire and uber-go/fx handle the wiring problem in idiomatic Go style. Those tools are not strictly necessary, but past a certain codebase size, hand-rolled main.go wiring becomes its own maintenance burden.