Skip to content
Kavindu's Blog
Go back

Clean, Hexagonal, Ports & Adapters: The Same Idea Wearing Three Names

Kavindu Manahara

Clean, Hexagonal, Ports & Adapters: The Same Idea Wearing Three Names

For about two years I thought Clean Architecture, Hexagonal Architecture, and Ports & Adapters were three competing schools of thought, and that picking one meant rejecting the other two. I even had a mild preference (Hexagonal, because the diagram looked cooler in a slide deck).

Then I went looking for the actual sources instead of the fortieth Medium recap of them, and found out two of those three names refer to the exact same thing. Alistair Cockburn published Hexagonal Architecture around 2005, then renamed it “Ports and Adapters” that same year because the hexagon was never the point, the ports were. So it’s not three architectures. It’s two, and one of them has a spare name.

That would have saved me a fair bit of arguing in code reviews.

The problem both of them are solving

Both patterns exist to keep business logic from depending on whatever database, UI framework, or delivery mechanism happens to be sitting next to it, so you can test the rules on their own and swap the infrastructure around them without touching the logic itself. Cockburn wrote his version in one sentence I keep coming back to: allow an application to be driven equally by users, automated tests, or batch scripts, and let it be developed and tested in isolation from whatever database or UI framework it happens to be running against today. Robert Martin’s Clean Architecture, published as a blog post in 2012 and later a book, is chasing the same outcome from a different angle: draw the business rules as the innermost circle, and make sure nothing in that circle ever imports, calls, or even knows the name of anything sitting in an outer circle.

Same complaint, really. Business logic keeps leaking into the database layer, or the controller, or wherever the framework wanted it to live, and now you can’t test it without spinning up a real Postgres instance. You can’t swap the ORM without touching the calculation code. Somebody adds a gRPC endpoint six months later and copies the validation logic instead of reusing it, because the validation logic was sitting inside a REST controller where nothing else could reach it.

Clean Architecture, in one picture

Clean Architecture is Robert Martin’s model for keeping business logic independent of infrastructure: draw it as the innermost of four concentric rings, and make sure nothing in that ring ever depends on the frameworks, databases, or UI sitting in the rings wrapped around it. Here’s the diagram everyone eventually copies into a slide deck without reading the post it came from:

The Clean Architecture: four concentric rings, Entities at the center, then Use Cases, then Interface Adapters, then Frameworks and Drivers on the outside, with a Dependency Rule arrow pointing inward
Diagram by Robert C. Martin, from "The Clean Architecture" (2012).

Four rings, read from the center out. Entities are the rules that would still be true if you deleted this codebase and rewrote it in a different language, the actual business concepts. Use Cases sit around them and describe what this specific application does with those concepts, place an order, calculate a quote. Interface Adapters convert data between the use case layer and whatever format the outside world speaks, a JSON body, a SQL row. Frameworks and Drivers is the outermost ring: your web framework, your database, your UI, glue code and nothing else.

The arrows all point inward. That’s the Dependency Rule, and it’s the entire diagram in one sentence: “source code dependencies can only point inwards.” Nothing in the Use Cases ring imports a class name from the Frameworks ring, not even to reference it. The confusing part, if you stare at the original diagram long enough, is the little crossing lines near the bottom right, showing a Controller calling into a Use Case Interactor through an input port, which calls back out through an output port to a Presenter. Control flow crosses the boundary in both directions. Source code dependency still only points one way, because that crossing happens through an interface the inner ring defined. That’s the Dependency Rule and the Dependency Inversion Principle doing the same job at once.

Hexagonal Architecture (aka Ports & Adapters), in one picture

Hexagonal Architecture, which Cockburn also calls Ports and Adapters, keeps the same rule but draws it as a hexagon: an application core sits in the middle, ports are the interfaces at its boundary, and adapters plug into those ports from the outside to connect it to the real world. Same idea, different picture, and this is the one Cockburn actually drew:

Hexagonal architecture diagram: an inner hexagon labeled Application surrounded by an outer hexagon of Adapters, connected through Ports at the boundary, with driving actors on the left and driven dependencies like a database on the right
Diagram by Cth027, CC BY-SA 4.0, via Wikimedia Commons.

The six sides mean nothing. Cockburn has said as much himself, he just needed a shape with enough room to draw several ports around the edge without the diagram looking like a one-directional layer cake with only a top and a bottom. The actual content is: an application core in the middle, ports as the interfaces sitting right at its boundary, and adapters plugged into those ports from the outside.

Ports split into two kinds depending on which way the arrow of initiative points. Primary ports (sometimes called driving or inbound) are how something outside asks the application to do work, a person clicking a button, a test harness, a scheduled job. Secondary ports (driven, or outbound) are what the application itself asks for, a place to save a record, a rate from a carrier. Cockburn’s own rule for the whole thing: “code pertaining to the inside part should not leak into the outside part.” Which is the Dependency Rule again, just said about a hexagon instead of a circle.

And here’s the detail that took me an embarrassingly long search to actually confirm: Ports & Adapters is not a third architecture sitting next to Hexagonal. Cockburn coined “hexagonal architecture” first, then renamed it “ports and adapters” in 2005 because the hexagon shape was never the point, people just kept fixating on it. Same pattern, same diagram, two names from the same person in the same year.

The one rule underneath all of it

Strip away both diagrams and there’s a single mechanical rule doing all the work in each of them: dependencies point inward, never outward. Your domain logic defines an interface for whatever it needs, a way to fetch a rate, a way to save a record, and something outside the domain provides the implementation. The domain never imports a database driver. It imports nothing but its own types and a handful of interfaces it wrote itself.

Different vocabulary, same direction of arrow. This is why I stopped treating “which one should we use” as a real question. Pick whichever vocabulary your team finds easier to say out loud in a standup, and you end up building the same shape either way.

Where Clean Architecture and Hexagonal genuinely differ: Clean Architecture is more prescriptive about how many rings you draw (the four named layers above) and insists your use-case layer has its own request and response models instead of passing domain entities straight through. Hexagonal only cares that there’s an inside and an outside, and that ports sit at the boundary. In a small service the distinction barely matters. Once you’ve got a dozen use cases, Clean Architecture’s extra layer earns its keep.

What this looks like with an actual example

Say you’re calculating a shipping quote. Given an order’s weight and destination, ask a carrier for a rate, wrap it in a domain object, and save it. Small enough to fit in a blog post, real enough to have an actual driven dependency (the carrier) and an actual driving one (whatever calls this thing, an HTTP handler here).

Here’s the shape, driving side on the left, driven side on the right:

flowchart LR
    A[HTTP handler] -->|calls| B[QuoteService / use case]
    B -->|depends on| C{{CarrierRates port}}
    B -->|depends on| D{{QuoteRepository port}}
    C -.implemented by.-> E[HTTP adapter to real carrier API]
    D -.implemented by.-> F[Postgres adapter]

The service in the middle never sees the HTTP adapter or the Postgres adapter directly. It only knows about the two ports, CarrierRates and QuoteRepository, and those are interfaces it wrote itself.

The domain, in Go

Go doesn’t have an implements keyword, which turns out to matter a lot here. A type satisfies an interface just by having the right methods, no declaration needed. So the domain package can define a port and never be imported by whatever ends up implementing it.

// domain/quote.go
package domain

type Quote struct {
	OrderID     string
	CarrierName string
	RateCents   int64
	Currency    string
}
// domain/ports.go
package domain

import "context"

type CarrierRates interface {
	GetRate(ctx context.Context, weightKg float64, destination string) (rateCents int64, carrier string, err error)
}

type QuoteRepository interface {
	Save(ctx context.Context, q Quote) error
}
// service/quote_service.go
package service

import (
	"context"

	"example.com/shipping/domain"
)

type QuoteService struct {
	rates domain.CarrierRates
	repo  domain.QuoteRepository
}

func NewQuoteService(rates domain.CarrierRates, repo domain.QuoteRepository) *QuoteService {
	return &QuoteService{rates: rates, repo: repo}
}

func (s *QuoteService) Calculate(ctx context.Context, orderID string, weightKg float64, destination string) (domain.Quote, error) {
	rateCents, carrier, err := s.rates.GetRate(ctx, weightKg, destination)
	if err != nil {
		return domain.Quote{}, err
	}

	q := domain.Quote{
		OrderID:     orderID,
		CarrierName: carrier,
		RateCents:   rateCents,
		Currency:    "USD",
	}

	if err := s.repo.Save(ctx, q); err != nil {
		return domain.Quote{}, err
	}

	return q, nil
}

QuoteService takes two interfaces in its constructor and doesn’t care where they come from. In a test, you hand it a fake CarrierRates that returns a fixed rate in a microsecond, no network call, no test container. In production, you hand it something that makes an HTTP request. That’s the whole trick, and it’s the same trick whether you call it a port or a dependency you inverted.

Wiring it by hand

This is the part that felt wrong the first time I did it in Go, coming from Spring. There’s no container scanning your code for @Component and matching constructor parameters. You wire it yourself, in main.go, as plain function calls.

// cmd/api/main.go
package main

import (
	"log"
	"net/http"

	"example.com/shipping/adapter"
	"example.com/shipping/service"
)

func main() {
	carrierAdapter := adapter.NewCarrierHTTPClient("https://carrier.example.com")
	quoteRepo := adapter.NewPostgresQuoteRepository(mustOpenDB())

	quoteSvc := service.NewQuoteService(carrierAdapter, quoteRepo)
	handler := adapter.NewQuoteHandler(quoteSvc)

	http.HandleFunc("/quotes", handler.Create)
	log.Fatal(http.ListenAndServe(":8080", nil))
}

carrierAdapter never needs to be declared as “a CarrierRates.” It just has a GetRate method with the matching signature, and Go accepts it wherever a CarrierRates is expected. No import from the adapter package back into the domain package either, which is the actual point, not the lack of a keyword.

The same shape in Spring Boot

Java doesn’t get the implicit-interface shortcut. You write implements CarrierRatesPort explicitly, every time. What you get back for that ceremony is a container that reads your classes at startup and wires them itself.

// domain/port/out/CarrierRatesPort.java
package com.example.shipping.domain.port.out;

public interface CarrierRatesPort {
    Rate getRate(double weightKg, String destination);
}
// domain/port/out/QuoteRepositoryPort.java
package com.example.shipping.domain.port.out;

import com.example.shipping.domain.Quote;

public interface QuoteRepositoryPort {
    void save(Quote quote);
}
// application/QuoteService.java
package com.example.shipping.application;

import com.example.shipping.domain.Quote;
import com.example.shipping.domain.port.out.CarrierRatesPort;
import com.example.shipping.domain.port.out.QuoteRepositoryPort;
import org.springframework.stereotype.Service;

@Service
public class QuoteService {

    private final CarrierRatesPort carrierRates;
    private final QuoteRepositoryPort quoteRepository;

    public QuoteService(CarrierRatesPort carrierRates, QuoteRepositoryPort quoteRepository) {
        this.carrierRates = carrierRates;
        this.quoteRepository = quoteRepository;
    }

    public Quote calculate(String orderId, double weightKg, String destination) {
        var rate = carrierRates.getRate(weightKg, destination);
        var quote = new Quote(orderId, rate.carrierName(), rate.cents(), "USD");
        quoteRepository.save(quote);
        return quote;
    }
}
// infrastructure/adapter/out/CarrierHttpAdapter.java
package com.example.shipping.infrastructure.adapter.out;

import com.example.shipping.domain.port.out.CarrierRatesPort;
import com.example.shipping.domain.port.out.Rate;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;

@Component
public class CarrierHttpAdapter implements CarrierRatesPort {

    private final RestClient restClient;

    public CarrierHttpAdapter(RestClient.Builder builder) {
        this.restClient = builder.baseUrl("https://carrier.example.com").build();
    }

    @Override
    public Rate getRate(double weightKg, String destination) {
        return restClient.get()
                .uri("/rates?weight={w}&dest={d}", weightKg, destination)
                .retrieve()
                .body(Rate.class);
    }
}
// infrastructure/adapter/in/QuoteController.java
package com.example.shipping.infrastructure.adapter.in;

import com.example.shipping.application.QuoteService;
import com.example.shipping.domain.Quote;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/quotes")
public class QuoteController {

    private final QuoteService quoteService;

    public QuoteController(QuoteService quoteService) {
        this.quoteService = quoteService;
    }

    @PostMapping
    public Quote create(@RequestBody CreateQuoteRequest req) {
        return quoteService.calculate(req.orderId(), req.weightKg(), req.destination());
    }
}

Nobody calls new CarrierHttpAdapter(...) anywhere. Spring finds the @Component, sees that QuoteService asks for a CarrierRatesPort in its constructor, notices CarrierHttpAdapter is the only bean implementing that interface, and hands it over at startup. That’s the whole difference from the Go version: same dependency graph, but a reflection-based container builds it instead of a human writing it out in main.go. Which one you’d rather debug at 2 AM is a matter of taste, and I genuinely go back and forth on it.

Where I’d actually use this

Reach for ports and adapters once you actually expect the pain it prevents: swapping or adding a driven adapter like a second database or payment provider, unit testing business rules without spinning up a real database, or supporting more than one driving adapter against the same use case. Not on every service. A small internal admin tool that reads and writes three tables and will never have a second UI or a second data store doesn’t need ports for its ports. The ceremony pays for itself once at least one of these is true: you expect to swap or add a driven adapter (a second database, a different payment provider), you need to unit test business rules without a real database, or more than one driving adapter needs the same use case (REST today, a worker queue or gRPC service next year).

The shipping quote example above is deliberately small enough that you could argue it doesn’t need this either. Fair. But the moment CarrierRates needs a second implementation, a different carrier for international orders, say, the interface is already sitting there waiting, and nothing about QuoteService has to change.

That’s really the whole bet you’re making. You pay a bit of indirection up front, in exchange for the option to change your mind later about a decision you haven’t made yet.


References


Share this post: