Skip to content

Explicit Architecture, Explained: DDD, Hexagonal, Onion, Clean, and CQRS in One Map

DDD, Hexagonal, Onion, Clean, and CQRS aren't five competing architectures. They're five views of the same idea, and Herberto Graça stitched them into one map he calls Explicit Architecture.1 This post walks through that map piece by piece, in his order, with one running Python example so you can see where every class actually lives.


Before you start

If ports, adapters, and the dependency rule are new to you, read Hexagonal vs Clean Architecture first. This post assumes those basics and spends its words on what Graça adds: components, component decoupling, data ownership, and flow of control.

The original article is from 2017. The mechanics haven't moved. What changed is the packaging: in 2026 a "component" is often a bounded context that later becomes its own service, and "events" usually means a broker plus the outbox pattern (covered in DDD in Action). Everything below still applies to a single deployable monolith — that's where it's most useful.

Our running example: a small online shop with three components — orders, billing, and accounts. We'll follow one use case end to end: a customer places an order, and billing issues an invoice. Value objects like Money, Percent, and OrderLine are assumed — see the value_objects.py section of DDD in Action for how to build them.


The Map at a Glance

Here's the whole thing in one picture. Every section below zooms into one part of it.

            DRIVING SIDE                                      DRIVEN SIDE
       (tools that tell the app)                        (tools the app tells)

   ┌───────────┐                                               ┌────────────┐
   │ Web / API │──┐                                        ┌──▶│ PostgreSQL │
   └───────────┘  │   ┌───────────────────────────────┐    │   └────────────┘
   ┌───────────┐  │   │        APPLICATION CORE       │    │   ┌────────────┐
   │    CLI    │──┼──▶│ ┌───────────────────────────┐ │────┼──▶│   Email    │
   └───────────┘  │   │ │    Application Layer      │ │    │   └────────────┘
   ┌───────────┐  │   │ │  ┌─────────────────────┐  │ │    │   ┌────────────┐
   │ Consumer  │──┘   │ │  │   Domain Services   │  │ │    └──▶│  Broker    │
   └───────────┘      │ │  │  ┌───────────────┐  │  │ │        └────────────┘
                      │ │  │  │ Domain Model  │  │  │ │
     primary /        │ │  │  └───────────────┘  │  │ │        secondary /
     driving          │ │  └─────────────────────┘  │ │        driven
     adapters         │ └───────────────────────────┘ │        adapters
     USE ports        │   ports live on this border   │        IMPLEMENT ports
                      └───────────────────────────────┘

             ALL source-code dependencies point INWARD, toward the Domain Model
Pattern What it contributes to the map
Ports & Adapters (Hexagonal) The border: ports inside, adapters outside, driving vs driven sides
Onion / Clean The concentric layers and the rule that dependencies only point inward
DDD What goes in the layers: application services, domain services, entities, value objects, events, bounded contexts
CQRS Splitting the flow into commands (change state) and queries (read state), optionally through a bus
Package by component Slicing the whole thing vertically by business capability, not by layer

1. Three Fundamental Blocks

Every application splits into three blocks:

  1. User Interface — whatever delivers the app: a web page, a REST API, a CLI, a mobile app.
  2. Application Core — the code that does the actual work. This IS your application.
  3. Infrastructure — the code that connects the core to tools: databases, search engines, email, message brokers, third-party APIs.

The flow is always the same: UI → Core → Infrastructure → Core → UI.

The core doesn't change when the UI changes. Your PWA, mobile app, CLI, and public API all trigger the same "place order" use case. If you have four copies of the ordering logic, one per UI, you don't have a core — you have four apps.

# The same use case, triggered from two different UIs.

# ui/http/orders.py (FastAPI)
@router.post("/orders")
def place_order_http(body: PlaceOrderBody, bus: CommandBus = Depends(get_bus)):
    bus.dispatch(PlaceOrder(customer_id=body.customer_id, items=body.items))

# ui/cli/orders.py (Typer)
@app.command()
def place_order_cli(customer_id: str, sku: str, qty: int):
    bus = get_bus()
    bus.dispatch(PlaceOrder(customer_id=customer_id, items=[(sku, qty)]))

Both entry points are ten lines of translation. Neither knows how an order is placed.


2. Tools, and the Two Directions of Control

A tool is anything outside your code that the app works with: the web server, the CLI runtime, PostgreSQL, Elasticsearch, SMTP, Kafka.

Graça's key observation is that tools come in two directions:

"The CLI console and the web server are used to tell our application to do something, the database engine is told by our application to do something."1

That one distinction decides how you connect each tool:

Tools that tell the app Tools the app tells
Examples Web server, CLI, queue consumer, cron Database, search engine, mailer, payment API
Who starts the conversation The tool Your core
Adapter type Primary / driving Secondary / driven
Adapter's relationship to the port Wraps and uses the port Implements the port

3. Ports: The Core's Own Contracts

"A port is nothing more than a specification of how the tool can use the application core, or how it is used by the Application Core."1

In Python that's a Protocol or an abstract base class, plus any DTOs it takes or returns.

The rule that people break most: ports are shaped by what the core needs, not by the tool's API. Graça is explicit: "the Ports are created to fit the Application Core needs and not simply mimic the tools APIs."1

# ❌ Mirrors the tool. The core now thinks in SMTP.
class SmtpPort(Protocol):
    def connect(self, host: str, port: int) -> None: ...
    def sendmail(self, from_addr: str, to_addrs: list[str], msg: bytes) -> None: ...

# ✅ Shaped by the core's need. SMTP, SES, or a test fake can all implement it.
class InvoiceNotifier(Protocol):
    def invoice_issued(self, customer_email: str, invoice_id: str, total: Money) -> None: ...

Ports live inside the core. Adapters live outside. That placement is what makes the dependency arrows point inward.


4. Adapters: Driving vs Driven

4.1 Primary (driving) adapters wrap a port

A driving adapter translates the outside world into a call on the core. In practice that's a controller or a console command, injected with something that implements a core port — an application service, a repository interface, or a command/query bus.

# ui/http/orders.py — a driving adapter
@router.post("/orders", status_code=202)
def place_order(body: PlaceOrderBody, bus: CommandBus = Depends(get_bus)) -> None:
    # 1. translate HTTP → Command   2. hand it to the core   3. that's it
    bus.dispatch(PlaceOrder(customer_id=body.customer_id, items=body.items))

4.2 Secondary (driven) adapters implement a port

A driven adapter implements a port and gets injected wherever the core asks for that port.

Graça's example: a naive app needs persistence, so you create "a persistence interface that meets its needs, with a method to save an array of data and a method to delete a line in a table by its ID."1 You implement it for MySQL. If you later move to PostgreSQL or MongoDB, "we just need to create a new adapter."1

# core/port/persistence.py — the port (inside the core)
class Persistence(Protocol):
    def save(self, table: str, data: dict) -> None: ...
    def delete(self, table: str, id: str) -> None: ...

# infrastructure/persistence/postgres.py — a driven adapter (outside)
class PostgresPersistence:
    def __init__(self, conn: psycopg.Connection):
        self._conn = conn

    def save(self, table: str, data: dict) -> None:
        cols = ", ".join(data)
        vals = ", ".join(f"%({k})s" for k in data)
        updates = ", ".join(f"{k} = EXCLUDED.{k}" for k in data)
        self._conn.execute(
            f"INSERT INTO {table} ({cols}) VALUES ({vals}) "
            f"ON CONFLICT (id) DO UPDATE SET {updates}", data)

    def delete(self, table: str, id: str) -> None:
        self._conn.execute(f"DELETE FROM {table} WHERE id = %s", (id,))

Swapping the database means writing MongoPersistence and changing one line in your composition root. Zero lines of the core change.

4.3 Inversion of control: why the arrows point inward

  • Adapters depend on a tool and a port.
  • The core depends on ports only — and ports are designed for the core.

So every dependency crossing the core's border points inward. That's the one rule Ports & Adapters, Onion, and Clean all share. The core doesn't know Postgres exists; Postgres doesn't know the core exists; only the adapter knows both.

You can enforce it in CI instead of hoping reviewers catch it:

# .importlinter
[importlinter]
root_package = shop

[importlinter:contract:core-is-pure]
name = Core must not import infrastructure or UI
type = forbidden
source_modules = shop.core
forbidden_modules =
    shop.infrastructure
    shop.ui
    sqlalchemy
    fastapi
pip install import-linter && lint-imports

5. Inside the Core: Application Layer and Domain Layer

The core itself is layered, DDD-style. Dependencies still only point toward the center.

┌───────────────────── Application Layer ─────────────────────┐
│  use cases: application services, command & query handlers  │
│  ports: repository, persistence, notifier, event dispatcher │
│  application events: OrderPlaced (use-case outcome)         │
│   ┌─────────────────── Domain Layer ──────────────────────┐ │
│   │  domain services: logic spanning several entities     │ │
│   │   ┌──────────────── Domain Model ──────────────────┐  │ │
│   │   │ entities, value objects, enums, domain events  │  │ │
│   │   │ depends on NOTHING outside itself              │  │ │
│   │   └────────────────────────────────────────────────┘  │ │
│   └───────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘

5.1 Application layer: the use cases

The application layer holds use cases — the processes a UI can trigger. It contains:

  • Application services (first-class citizens) and their interfaces
  • Port interfaces for ORM, search, messaging, email
  • Command handlers and query handlers, if you use a bus
  • Application events, which announce the outcome of a use case

A typical application service does three things: load entities from a repository, tell them to do domain logic, save them back.

# core/component/orders/application/place_order.py
class PlaceOrderService:
    def __init__(self, orders: OrderRepository, customers: CustomerRepository,
                 pricing: PricingService, events: EventDispatcher):
        self._orders, self._customers = orders, customers
        self._pricing, self._events = pricing, events

    def place(self, customer_id: str, items: list[tuple[str, int]]) -> str:
        customer = self._customers.get(customer_id)          # 1. load
        order = Order.create(customer_id, items)             # 2. domain logic
        self._pricing.apply_discounts(order, customer)       #    (domain service)
        self._orders.add(order)                              # 3. persist
        self._events.dispatch(OrderPlaced(order.id, customer_id, customer.email, order.total))
        return order.id

Application events trigger side effects — send the confirmation email, notify a third-party API, push a notification, or start a use case in another component (billing, below).

Command handlers come in two flavours. Graça lists both:1

Style What the handler does Pick it when
Handler is the use case Contains the load → act → save logic itself You're starting fresh and the bus is your only entry point
Handler is wiring Receives the command, calls an existing application service You already have application services, or need the same logic from non-bus entry points
# Wiring style — the handler is three lines of glue
class PlaceOrderHandler:
    def __init__(self, service: PlaceOrderService):
        self._service = service

    def __call__(self, cmd: PlaceOrder) -> None:
        self._service.place(cmd.customer_id, cmd.items)

5.2 Domain services: logic that spans several entities

Some business rules touch more than one entity. "Apply a loyalty discount based on the customer's tier and the order's contents" needs both Customer and Order. It doesn't belong to either.

The tempting mistake is to put it in the application service. That breaks encapsulation and, worse, you can't reuse it — the next use case that needs discounting copies the code.

The fix is a domain service: it takes entities in, runs business logic, and knows nothing about repositories or application services.

# core/component/orders/domain/pricing.py
class PricingService:
    """Domain service. No repositories, no I/O, no application-layer imports."""

    def apply_discounts(self, order: Order, customer: Customer) -> None:
        if customer.tier is Tier.GOLD and order.subtotal >= Money("100.00"):
            order.apply_discount(Percent(10), reason="gold-tier")

Domain services may use other domain services and domain model objects. They never reach up into the application layer.

5.3 Domain model: the center

The innermost circle holds entities, value objects, enums, and anything else that is a business concept. It depends on nothing outside itself.

# core/component/orders/domain/order.py
@dataclass
class Order:
    id: str
    customer_id: str
    lines: list[OrderLine]
    discount: Percent = Percent(0)
    events: list[DomainEvent] = field(default_factory=list)

    @property
    def subtotal(self) -> Money:
        return sum((l.total for l in self.lines), Money("0"))

    def apply_discount(self, pct: Percent, reason: str) -> None:
        if pct > Percent(30):
            raise DiscountTooLarge(pct)
        self.discount = pct
        self.events.append(DiscountApplied(self.id, pct, reason))

Domain events fire when domain data changes and carry the change with them (DiscountApplied above). They're the natural building block if you ever adopt event sourcing.

Application event vs domain event

Domain event = "this data changed" (DiscountApplied). Application event = "this use case finished" (OrderPlaced). One use case can emit many domain events and one application event.


6. Components: Slice by Business, Not by Layer

So far we've cut the code horizontally, into layers. Graça adds a vertical cut: components, aligned with sub-domains and bounded contexts. This is Uncle Bob's "screaming architecture" — the top-level folders should scream what the app does, not what framework it uses.

Packaging Top-level folders say… Change "how billing works" touches…
By layer controllers/, services/, repositories/ Every folder
By feature orders/, billing/ — each with its own layers, all public One folder, but anything can import anything
By component orders/, billing/ — each with its own layers, and components can't see each other One folder, with enforced boundaries
# ❌ Package by layer — the architecture screams "Django"
shop/
├── controllers/   (orders, billing, accounts mixed together)
├── services/
├── repositories/
└── models/
# ✅ Package by component — the architecture screams "shop"
shop/
├── core/
│   ├── component/
│   │   ├── orders/
│   │   │   ├── application/   place_order.py, queries.py, events.py, ports.py
│   │   │   └── domain/        order.py, pricing.py, events.py
│   │   ├── billing/
│   │   │   ├── application/   issue_invoice.py, ports.py
│   │   │   └── domain/        invoice.py
│   │   └── accounts/
│   │       ├── application/
│   │       └── domain/
│   ├── port/                  ports shared by all components (EventDispatcher, CommandBus)
│   └── shared_kernel/         events & specs every component may import — keep tiny
├── infrastructure/            driven adapters: postgres/, smtp/, kafka/
└── ui/                        driving adapters: http/, cli/, consumers/

Graça also notes that bounded contexts like authentication and authorization are best treated as external tools: something you reach through a port and an adapter, not a component your core owns.

Add a contract so the boundaries hold:

[importlinter:contract:components-independent]
name = Components must not import each other
type = independence
modules =
    shop.core.component.orders
    shop.core.component.billing
    shop.core.component.accounts

7. Decoupling Components

Inside a component you decouple with dependency injection and dependency inversion — depend on interfaces, not concrete classes.

Between components the bar is higher. A component must have zero direct knowledge of another component — no imports of its classes, not even its interfaces. That needs bigger tools: events, a shared kernel, eventual consistency, and sometimes a discovery service.

7.1 Triggering logic in another component: events

When an order is placed, billing must issue an invoice. orders calling billing.IssueInvoiceService directly would couple them.

Instead, orders dispatches OrderPlaced, and billing listens.

# core/component/billing/application/issue_invoice.py
class IssueInvoiceOnOrderPlaced:
    def __init__(self, invoices: InvoiceRepository, notifier: InvoiceNotifier):
        self._invoices, self._notifier = invoices, notifier

    def __call__(self, event: OrderPlaced) -> None:
        invoice = Invoice.for_order(event.order_id, event.customer_id, event.total)
        self._invoices.add(invoice)
        self._notifier.invoice_issued(event.customer_email, invoice.id, invoice.total)

# composition root (outside the core)
dispatcher.subscribe(OrderPlaced, IssueInvoiceOnOrderPlaced(invoices, notifier))

orders no longer knows billing exists. But notice the leftover coupling: both components know the OrderPlaced class. If it lives in orders/, billing imports from orders and you've gained nothing.

7.2 The shared kernel: where the events live

The fix is a shared kernel — a small library of core code that every component may depend on, while staying decoupled from each other. It holds application and domain events, specification objects, and very little else.

# core/shared_kernel/events.py
@dataclass(frozen=True)
class OrderPlaced:
    order_id: str
    customer_id: str
    customer_email: str
    total: Money

Two rules keep it healthy:

  • Keep it tiny. Every change to it touches every component. If a type isn't needed by at least two components, it doesn't belong there.
  • Go language-agnostic in a polyglot system. Instead of a Python class, the kernel holds "the event description (ie. name, properties…) in an agnostic language like JSON."1 In 2026 that's usually JSON Schema, Avro, or Protobuf in a schema registry.
{
  "$id": "shop.orders.OrderPlaced.v1",
  "type": "object",
  "required": ["order_id", "customer_id", "customer_email", "total"],
  "properties": {
    "order_id": { "type": "string" },
    "customer_id": { "type": "string" },
    "customer_email": { "type": "string", "format": "email" },
    "total": { "type": "string", "pattern": "^\\d+\\.\\d{2}$" }
  }
}

7.3 When you can't wait: synchronous calls via discovery

Sometimes the other component must act right now, and an async event won't do. Then component A makes an HTTP request to component B — but through a discovery service, never a hard-coded address:

  • A asks the discovery service where to send the request, then calls B, or
  • A sends the request to the discovery service, which proxies it to B and returns the response.

A is now coupled to the discovery service, but still not to B. In Kubernetes, cluster DNS plus a Service already plays this role (http://billing.shop.svc.cluster.local); a service mesh plays the proxy variant.

7.4 Getting data from another component: the ownership rule

"A component is not allowed to change data that it does not 'own', but it is fine for it to query and use any data."1

How you apply that depends on your storage:

Shared data storage Segregated data storage
Setup One database, tables owned by different components One database (or schema) per component
Reading another component's data Query its tables directly, read-only Keep a local copy of just the fields you need
Keeping it fresh Always fresh — it's the same table Owner emits domain events on change; the copy updates itself (eventual consistency)
Writing another component's data Never Never
Cost Cheap, but schemas couple at the SQL level More moving parts; each component can be split into its own service later
# Segregated storage: billing keeps its own copy of customer contact data.
# core/component/billing/application/sync_customer.py
class SyncCustomerContact:
    def __init__(self, contacts: BillingContactRepository):
        self._contacts = contacts

    def __call__(self, event: CustomerEmailChanged) -> None:   # emitted by accounts
        self._contacts.upsert(event.customer_id, email=event.new_email)

Billing reads its local billing_contacts table. It never writes to accounts. Accounts stays the single source of truth.


8. Flow of Control: Who Calls Whom

The last piece shows how the classes wire together at runtime. There are two variants.

8.1 Without a command/query bus

Controller ──▶ Application Service ──▶ Repository (port) ◀── Postgres adapter
    │                 │                       │
    │                 ├──▶ Domain Service     └── returns Entities
    │                 ├──▶ Entity.do_logic()
    │                 └──▶ EventDispatcher (port) ──▶ OrderPlaced
    │
    └──▶ Query (port) ◀── SQL query adapter ── returns DTO ──▶ ViewModel ──▶ View
  • Commands (writes): the controller calls an application service, which loads entities through a repository interface, runs domain logic, saves, and dispatches events.
  • Queries (reads): the controller calls a query object that runs one optimized SQL statement and returns a plain DTO. No entities, no repository. The DTO feeds a ViewModel, which holds view logic (formatting, labels) and fills the view.
# core/component/orders/application/queries.py — the port
@dataclass(frozen=True)
class OrderSummary:
    order_id: str
    placed_at: datetime
    total: str
    item_count: int

class OrderSummaries(Protocol):
    def for_customer(self, customer_id: str, limit: int = 20) -> list[OrderSummary]: ...

# infrastructure/persistence/postgres_queries.py — the adapter
class PgOrderSummaries:
    def for_customer(self, customer_id: str, limit: int = 20) -> list[OrderSummary]:
        rows = self._conn.execute("""
            SELECT o.id, o.placed_at, o.total::text, count(l.*)
            FROM orders o JOIN order_lines l ON l.order_id = o.id
            WHERE o.customer_id = %s
            GROUP BY o.id ORDER BY o.placed_at DESC LIMIT %s
        """, (customer_id, limit)).fetchall()
        return [OrderSummary(*r) for r in rows]

Why both a persistence interface and a repository interface? They abstract different things:

Interface Abstracts Lets you swap
Persistence The ORM / database driver MySQL → MongoDB without touching repositories' callers
Repository The queries the domain needs Vendor-specific query styles, while reusing the same persistence adapter

In small apps you'll often collapse the two. That's fine — just know which one you collapsed.

8.2 With a command/query bus

Controller ──creates──▶ PlaceOrder (command)
    │
    └──▶ CommandBus (port) ··· config lookup ···▶ PlaceOrderHandler
                                                     │
                                                     └──▶ (optional) PlaceOrderService
  • The controller depends only on the bus interface and the command/query classes.
  • The bus finds the handler through configuration, not code. Bus, command, and handler don't import each other.
  • The handler holds the use case, or delegates to an application service (the two flavours from §5.1).
# core/port/bus.py — the port
class CommandBus(Protocol):
    def dispatch(self, command: object) -> None: ...

# infrastructure/bus/in_memory.py — a minimal adapter
class InMemoryCommandBus:
    def __init__(self, handlers: dict[type, Callable[[Any], None]]):
        self._handlers = handlers

    def dispatch(self, command: object) -> None:
        self._handlers[type(command)](command)

# composition root — the only place that knows the mapping
bus = InMemoryCommandBus({
    PlaceOrder:   PlaceOrderHandler(place_order_service),
    CancelOrder:  CancelOrderHandler(cancel_order_service),
})

Swap InMemoryCommandBus for one that serializes commands onto a queue, and your controllers don't change.

Without bus With bus
Controller depends on Application services, query objects Bus interface + command/query classes
Use-case logic lives in Application services Handlers, or services behind them
Finding the handler Direct method call Configuration lookup
Adding cross-cutting behaviour (logging, transactions, retries) Decorators on each service One bus middleware
Extra indirection None One hop — harder to "go to definition"

In both variants, every arrow that crosses the core's border points inward. That's the invariant.


9. The Map Is Not the Territory

Graça closes with the line that matters most:

"The map is not the territory… these are just guidelines! The application is the territory, the reality, the concrete use case."1

He's not asking you to build every box on day one. He's asking you to understand every box, then decide how far to push decoupling based on the functional requirements, the timeline, the team's experience, and how long the app will live.

For what it's worth, this isn't whiteboard theory: he reports running these ideas in production on a SaaS e-commerce platform with thousands of web shops, and a marketplace live in two countries whose message bus "handles over 20 million messages per month."1


Summary

The whole map, as a rule list:

  • Core in the middle. UI and infrastructure are replaceable plug-ins.
  • Ports belong to the core and are shaped by its needs, never by a tool's API.
  • Driving adapters use ports (controllers, CLI, consumers). Driven adapters implement ports (DB, mail, broker).
  • Application layer = use cases. Load → act → save → dispatch event.
  • Multi-entity rules go in domain services, never in application services.
  • Domain model depends on nothing.
  • Package by component. Components never import each other.
  • Cross-component = events + a tiny shared kernel. Sync calls go through discovery.
  • Only the owner writes its data. Everyone else reads, or keeps an event-fed copy.
  • Enforce it in CI with import contracts, not code review.

And the decision framework — how far to go, based on what your project actually needs:

Add this… …when Skip it when
Ports & adapters for a tool You'll test the core without it, or might swap it It's a stable, boring dependency and the app is short-lived
Separate query objects (CQRS-lite) Read screens need joins/aggregates that fight your entities Reads map 1:1 to entities
Command/query bus 3+ entry points (HTTP, CLI, consumers) or you want one place for transactions/retries One HTTP API and a small team — call services directly
Components with import contracts 2+ teams, or 3+ distinct sub-domains One team, one sub-domain
Shared kernel 2+ components exchange events Only one component exists
Segregated storage per component You plan to split components into services, or teams fight over schemas One team, one database, no split planned
Discovery service for sync calls Components deploy separately Everything ships as one process — use in-process events

  1. Herberto Graça, DDD, Hexagonal, Onion, Clean, CQRS, … How I put it all together, 16 November 2017. Source of the Explicit Architecture model, the quoted definitions, and the production figures. Code examples in this post are my own illustrations. ↩↩↩↩↩↩↩↩↩↩↩

Discussion

Have thoughts on this post? Share them below — questions, corrections, or your own experience are all welcome.