Overview
The back end is hexagonal — ports and adapters — rather than a stack of layers. The domain sits in the middle and declares the interfaces it needs; everything technical is an adapter plugged into one of those interfaces, and every reference points inward.
The difference shows in the questions you ask. A layered reading asks "which layer is
this in?" and grows one Application project that every feature ends up in. A
hexagonal reading asks "whose rule is this?" and "is this a port or an adapter?",
and produces one project per domain, with the technical world outside, replaceable.
Solid arrows are calls. The dotted one is the twist that gives the pattern its value: a driven adapter implements a port the domain declares, so the code depends on the port — the domain never references Wolverine, EF Core or SMTP.
The vocabulary
| Term | Meaning here | Where it lives |
|---|---|---|
| Driving port | What the outside may ask the domain to do | Blouppy.<Domain>.Abstractions (IWorkspacesService), Blouppy.ApiQuery.Abstractions for reads |
| Driven port | What the domain needs from the outside | Blouppy.Infrastructure.<Concern>.Abstractions (IWorkspacesRepository, IEmailSender) |
| Driving adapter | Turns a request into a call on a driving port | Blouppy.Web, the message consumers, the MCP server |
| Driven adapter | Implements a driven port with a real technology | Blouppy.Infrastructure.<Concern> |
| Composition root | The one place that wires adapters to ports | Blouppy.Web (AddDomainServices(), AddInfrastructureServices()) |
A driven port has no Service suffix: it is IWorkspacesRepository, not
IWorkspacesRepositoryService.
Three rules
- A port lives in an
.Abstractionsproject. The domain declares what it needs — a repository, a file store, a mailer — and never sees who implements it. - One project per domain, and domains do not touch. A domain needing a fact about
another goes through that domain's
.PublicDatacontract or an integration event — never a peer's repository, service or entity. - Reads and writes are separate verticals. A
GETresolves throughBlouppy.ApiQueryand its projections; everything else goes through the domain's own service and returns aValidationResult<T>.
These rules are not left to code review. Blouppy.Architecture.UnitTests reads the
compiled assemblies and fails dotnet test when a reference points the wrong way:
| Rule | Test class |
|---|---|
| Domains do not reference one another, nor the infrastructure, the Web or the read side | DomainBoundaryTests |
Only the read side injects a read repository; read projections stay in ApiQuery.Models | ReadSideBoundaryTests |
| Each adapter references only the projects its row allows; no port references an adapter | InfrastructureBoundaryTests |
| Nothing outside the messaging adapter references Wolverine | BusLibraryBoundaryTests |
| Every write of a driving port is audited, or says why not | AuditCoverageTests |
The map
back/
├── Blouppy.SharedKernel/ base entities, typed ids, results, value objects
├── Blouppy.<Domain>[.Models] one set per domain: Workspaces, Organizations,
│ [.Abstractions][.PublicData] Users, Notifications
├── Blouppy.ApiQuery[.Abstractions] the read side: projections, filters, paging
│ [.Models]
├── Blouppy.Billing, .Privacy, modules: shaped like a domain,
│ .Webhooks, .Audit, .Ai each owning a feature
├── Blouppy.Infrastructure.<Concern> one driven adapter each, its port in .Abstractions
├── Blouppy.Web/ the HTTP adapter and composition root
├── Blouppy.AgentTools, .Mcp, the tools shared by the MCP server and the assistant
│ .Assistant
├── Blouppy.Gateway/ YARP + the session cookie (the BFF)
├── Blouppy.Identity/ the OpenID Connect server, its own hexagon
├── Blouppy.MigrationRunner/ applies every migration, then exits
└── Blouppy.Aspire.AppHost/ the development graph
Each piece has its own page:
- Domains and modules — what a domain is made of, and how two talk.
- Reads and writes — the two verticals.
- Events and messaging — domain events, integration events, the outbox.
- Gateway and session — why the browser never holds a token.
- Identity — the OpenID Connect server.
- Real time — one event stream for the whole app.
- Other adapters — cache, files, e-mail, scheduled jobs.
- A request, end to end — a write and a read, step by step.