Onion Architecture
Why I Moved Beyond Simple Layering
After a year of working with layered architecture in the POS system, I noticed the same recurring problem: the domain layer still had implicit knowledge of the database. SQLAlchemy models subclassed from Base. Repository classes imported from sqlalchemy.orm. Changing the ORM version or switching to a different database meant touching domain code.
Onion architecture pushed the domain completely to the centre β with a strict rule that nothing in the domain layer imports from anything outside it. The infrastructure (databases, HTTP clients, external services) lives at the outermost ring and points inward. The domain never imports from infrastructure.
This was a meaningful change. It made the domain genuinely testable without a database. It made the system's business rules readable without understanding any infrastructure choices.
Table of Contents
What Is Onion Architecture?
Onion architecture (introduced by Jeffrey Palermo) organises code as concentric rings. The inner rings define abstractions; the outer rings implement them. Dependencies always point inward β outer rings can depend on inner rings, but inner rings can never depend on outer rings.
The key difference from layered architecture: the database is not at the bottom of the stack. It is at the outermost ring, alongside HTTP controllers and CLI commands. The domain is in the centre and knows nothing about any of them.
The Rings
Ring 1: Domain (Centre)
Entities β objects with identity that persist across time (e.g.,
User,Order,Product)Value Objects β immutable objects defined by their attributes, not identity (e.g.,
Email,Money,TenantId)Domain Services β operations that do not naturally belong to a single entity
Domain Events β facts that have happened within the domain
Rule: Zero imports from outside this ring. No ORM base classes, no HTTP types, no third-party libraries.
Ring 2: Application
Use Cases / Application Services β orchestrate domain objects to fulfil business goals
Repository Interfaces β abstract definitions of persistence operations (not implementations)
Service Interfaces β abstract definitions of external capabilities (email, notifications, etc.)
DTOs β data objects for passing data across ring boundaries
Rule: Can import from Domain. Does not import from Infrastructure.
Ring 3: Infrastructure
Repository Implementations β concrete database implementations of the interfaces defined in Application
External Service Clients β HTTP clients, email providers, file storage
ORM Models β SQLAlchemy classes or equivalent
Rule: Can import from Application. Implements Application-defined interfaces. Never imported by Application or Domain.
Ring 4: User Interface (Outermost)
HTTP Controllers / Routers β FastAPI routers, Flask views
CLI Commands
Background Job Handlers
Event Consumers
Rule: Depends on Application through use case classes. Never bypasses Application to call Domain directly.
The Dependency Inversion Principle in Practice
The mechanism that makes the onion work is dependency inversion: high-level modules (domain, application) do not depend on low-level modules (infrastructure). Both depend on abstractions (interfaces).
The Application ring defines UserRepository as an abstract interface. The Infrastructure ring implements it. The Application (use cases) receives the repository through dependency injection β it never knows whether it is talking to PostgreSQL, an in-memory dict, or a test double.
Practical Example: Auth Service with Onion Structure
The domain model User has no database imports. The use case AuthenticateUserUseCase has no SQLAlchemy or bcrypt imports. The router wires everything together and handles HTTP.
Project Structure
Testing Without a Database
The major payoff of onion architecture: domain and use case tests require no database:
These tests run in milliseconds with no database setup, no Docker, no test fixtures beyond Python objects.
When Onion Architecture Pays Off
Onion architecture is worth the structure when:
The domain logic is complex and changes frequently
The application must support multiple interfaces (REST API, CLI, background jobs)
Infrastructure choices may change (switching databases, adding a new external service)
Comprehensive unit testing of business rules is a priority
A team of developers needs to work in the codebase simultaneously with clear boundaries
It is overkill for:
Simple CRUD applications with minimal business logic
Small personal tools
Proof-of-concept work where speed matters more than structure
Lessons Learned
The centre is the most valuable part. If the domain ring is rich and clean, the rest of the system is easier.
Abstract interfaces feel like ceremony until you need to swap an implementation. When I replaced my email provider, I implemented one interface and changed one line of wiring.
Dependency injection is the mechanism that makes the onion possible. Without it, you are importing concrete implementations and the inversion is broken.
Do not put application logic in the infrastructure ring. I have seen repositories with conditional business rules inside them. That is domain/application logic that has leaked outward.
Last updated