Documentation

Hexagonal Architecture

Ports & adapters in depth — how interfaces decouple the domain from infrastructure and enable adapter swapping.

Show:

The domain layer — your business logic — should have zero knowledge of how it's deployed, what database it uses, or what HTTP framework sits in front of it. Hexagonal architecture enforces this by inverting dependencies: instead of the domain calling into infrastructure, the domain defines interfaces (ports) and infrastructure implements them (adapters). Swap the database. The domain doesn't notice.


Ports & Adapters

A port is an interface defined in the domain or application layer. It declares what the application needs — without specifying how it will be provided.

# domain/repositories/account_repository.py
class IAccountRepository(Protocol):
    async def get_by_id(self, account_id: str) -> Account | None: ...
    async def add(self, account: Account) -> None: ...
// domain/repositories/accountRepository.ts
export interface IAccountRepository {
  getById(id: string): Promise<Account | null>
  add(account: Account): Promise<void>
}

An adapter is a concrete implementation of that interface, living in the infrastructure layer. It knows about SQLAlchemy, Prisma, Redis, or whatever external system — the domain does not.

# infrastructure/database/account_repository.py
class SQLAlchemyAccountRepository:
    def __init__(self, session: AsyncSession):
        self.session = session
 
    async def get_by_id(self, account_id: str) -> Account | None:
        result = await self.session.execute(
            select(AccountModel).where(AccountModel.id == account_id)
        )
        model = result.scalar_one_or_none()
        return self._to_domain(model) if model else None
 
    async def add(self, account: Account) -> None:
        self.session.add(self._to_model(account))
// infrastructure/database/prismaAccountRepository.ts
export class PrismaAccountRepository implements IAccountRepository {
  constructor(private readonly prisma: PrismaClient) {}
 
  async getById(id: string): Promise<Account | null> {
    const row = await this.prisma.account.findUnique({ where: { id } })
    return row ? toDomain(row) : null
  }
 
  async add(account: Account): Promise<void> {
    await this.prisma.account.create({ data: toRow(account) })
  }
}

The domain only imports IAccountRepository. The infrastructure layer imports both — it depends on the interface to implement it. This is dependency inversion: the dependency arrow points from infrastructure toward the domain, not away from it.


Dependency Direction

Dependencies always point inward. The domain has no imports from outer layers. Application code imports from the domain. Infrastructure imports from both application and domain to implement their interfaces. The API layer imports from application.

Infrastructure → Application → Domain
     API        → Application → Domain

A practical consequence: you can test every piece of domain and application logic with zero real infrastructure — no database, no network, no external service. Replace each adapter with an in-memory implementation and the tests run in milliseconds.


Adapter Varieties

Any external concern can be modelled as a port with multiple adapters:

PortIn-memory adapter (tests / local)Production adapter
IAccountRepositoryInMemoryAccountRepositorySQLAlchemyAccountRepository
IEventPublisherInMemoryEventPublisherSNSEventPublisher
IStorageAdapterLocalFileStorageAdapterS3StorageAdapter
INotificationProviderInMemoryNotificationAdapterSendGridAdapter

Switching between adapters requires no application code changes — only the factory function that constructs the adapter is environment-aware. See Configuration & Settings for the adapter-switching pattern.


Related Documents

  • Layered Architecture — the four layers (domain, application, infrastructure, API) in detail
  • Repository Pattern — the repository port & adapter as a worked example
  • Event-Driven Patterns — the event publisher port
  • Testing Strategy — how in-memory adapters make the test suite fast and deterministic

Reference implementation: domain/ and infrastructure/ layers across any service.

Framework implementations

This guide covers how our abstract architecture standards are implemented in FastAPI. It does not re-explain DDD or hexagonal architecture — see the references below for those.

See also:


How the Layers Map

Abstract layerFastAPI folderRole
Domaindomain/Aggregates, entities, value objects, abstract repository interfaces. Zero external dependencies.
Applicationapplication/Use-case handlers (commands), query handlers, abstract service interfaces (publisher, logger, monitor, UoW).
Infrastructureinfrastructure/Concrete adapter implementations — database, events, cache, storage, monitoring, logging, auth.
API (Driving adapter)api/FastAPI routes, middleware, request/response schemas, DI container, exception handlers.
Configurationconfig/Pydantic BaseSettings — environment variables, adapter selection, feature flags.

Dependency direction: api/application/domain/. infrastructure/ implements interfaces defined in application/ and domain/. config/ is read by the infrastructure factories and the DI container only.


Key FastAPI Idioms

Dependency Injection via Depends() FastAPI's Depends() is the mechanism for injecting services into routes. All service construction happens in api/dependencies.py. Routes declare what they need via function parameters — FastAPI resolves and injects them.

Adapter switching via settings Each infrastructure concern (database, events, storage, monitoring) has multiple adapter implementations and a factory. The factory reads a settings field (e.g. event_adapter = "inmemory" | "sns") and returns the appropriate implementation. This allows swapping adapters without changing application code.

Middleware for cross-cutting concerns Logging, auth, request context, and CORS are handled as ASGI middleware registered on the FastAPI app — not inside route handlers. See 04-middleware-stack.md.

Lifespan for startup/shutdown Database connection pools, background flush tasks (monitoring), and other process-level resources are initialised and torn down in api/lifespan.py using FastAPI's lifespan context manager.


Reference Implementation

All patterns in this guide reflect a canonical FastAPI service implementing the full four-layer structure described above.