Domain-Driven Design (DDD), introduced by Eric Evans, is a software design philosophy aimed at creating systems that closely align with the business domain. At its core, DDD revolves around the domain model, a rich and precise representation of the rules, processes, and concepts of the business domain.
While DDD provides principles and practices for modeling and understanding the business domain, it doesn’t prescribe how the application should be structured. This is where Onion Architecture steps in, offering a robust architectural approach to implement DDD principles effectively.
Onion Architecture complements DDD by structuring application layers in a way that protects the domain model from external dependencies. By enforcing dependency inversion, Onion Architecture ensures that:
- The Domain Model remains the core focus and the heart of the system.
- Critical business logic is isolated from technical concerns such as databases, APIs, or user interfaces.
Onion Architecture: Explained
Onion Architecture organizes software into concentric layers where:
- The core (center) represents the Domain Model and business logic.
- Outer layers handle infrastructure, UI, and external services, all dependent on the core.
Visual Representation of Onion Architecture
The image provided is a perfect representation of Onion Architecture:
- Domain Model (Core): The innermost layer, representing business rules.
- Use Cases: Encapsulates specific business workflows or actions.
- Application Services: Coordinates between the domain and infrastructure layers.
- Infrastructure Services: Handles technical concerns like databases and APIs.
- Presentation/UI Layer: Interfaces with the user (e.g., APIs, UIs).
The arrow in the diagram emphasizes dependency flow, pointing toward the core. This ensures that:
- Outer layers depend on inner layers.
- The core domain is protected from changes in the outer layers.
Principles of Onion Architecture
-
Dependency Inversion:
- The innermost layers (core logic) are independent of external systems.
- External systems, like the database or UI, depend on abstractions defined in the domain.
-
Separation of Concerns:
- Each layer has a distinct responsibility:
- Domain Layer: Core business rules.
- Application Layer: Orchestration and workflows.
- Infrastructure Layer: External integrations.
- Presentation Layer: User-facing concerns.
- Each layer has a distinct responsibility:
-
Interface-Driven Design:
- Inner layers define interfaces that the outer layers implement, promoting loose coupling.
-
Focus on the Domain:
- The domain model is the most critical part of the application.
- All other layers exist to support the domain, not the other way around.
Advantages of Onion Architecture
-
Independence:
- The core business logic is not affected by changes in frameworks, databases, or other technologies.
- Makes it easy to switch external tools without rewriting core logic.
-
Testability:
- Inner layers (e.g., services, domain logic) can be tested in isolation using mock implementations.
-
Scalability:
- Clean separation of layers ensures that new features or modules can be added without disrupting the existing structure.
-
Maintainability:
- Clear boundaries between layers reduce the risk of introducing bugs when making changes.
-
Alignment with DDD:
- Onion Architecture supports DDD by focusing on the domain model and ensuring that external concerns don't pollute core logic.
Challenges of Onion Architecture
-
Initial Complexity:
- Designing interfaces and abstractions for every layer may seem redundant for small projects.
- Requires developers to understand dependency inversion.
-
Verbose Code:
- The need for additional boilerplate, such as interfaces and dependency injection, may slow down initial development.
-
Steep Learning Curve:
- Teams unfamiliar with architectural patterns or DDD may find Onion Architecture challenging to adopt.
-
Overhead in Simple Applications:
- For simple CRUD-based applications, the benefits may not justify the added complexity.
Scaling Onion Architecture
1. Microservices
-
Bounded Context Separation: Each domain area (e.g.,
User Management
,Order Management
) becomes a separate microservice. - Independent Onion Layers: Each microservice has its own Onion Architecture, including domain, application, infrastructure, and presentation layers.
-
Scalable Independently: Scale only the services under heavy load (e.g., scale
Order Service
during a sale). - Decoupled Systems: Microservices interact via APIs or message brokers, reducing dependencies.
- Tech Stack Flexibility: Use different technologies for different services based on requirements (e.g., Node.js for APIs, Python for ML models).
- Database Per Service: Each microservice owns its database, ensuring autonomy and consistency.
2. Event-Driven Systems
-
Domain Events: Services emit events (e.g.,
OrderPlaced
,ProductUpdated
) to notify other services about domain changes. - Event Brokers: Use tools like RabbitMQ, Kafka, or AWS SNS/SQS for asynchronous communication.
- Loose Coupling: Services don’t call each other directly but react to events, reducing dependencies.
- Asynchronous Processing: Improves performance by handling tasks (e.g., sending an email) in the background.
- Scalability of Event Handlers: Event consumers can scale independently to handle spikes in events.
3. Modular Design
- Shared Kernel for Common Functionality: A shared module for authentication, logging, and monitoring to avoid duplication.
- Clear Module Boundaries: Ensure each module operates independently to reduce interdependencies.
- Easier Maintenance: Modules can be developed, tested, and deployed separately.
4. Observability
- Centralized Logging: Use tools like ELK Stack (Elasticsearch, Logstash, Kibana) for logs across all microservices.
- Distributed Tracing: Tools like OpenTelemetry or Jaeger trace requests across services.
- Real-Time Monitoring: Use Prometheus and Grafana for health metrics and alerts.
5. Fault Tolerance
- Circuit Breakers: Implement patterns (e.g., using libraries like Hystrix) to prevent cascading failures between services.
- Retry Policies: Automatically retry failed operations in event-driven systems.
- Dead Letter Queues: Handle failed messages safely in event systems.
By applying these principles, Onion Architecture can scale from a monolithic application to a distributed, microservices-based system, supporting both functional and non-functional requirements seamlessly.
Coded Example: Scalable Onion Architecture
This example implements Onion Architecture for a product and user management system with modularity in mind. Each module follows the Onion structure, with its own layers.
1. Project Structure
onion-architecture/
|-- src/
|-- modules/
|-- product/
|-- domain/
|-- entities/
|-- Product.ts
|-- interfaces/
|-- ProductRepository.ts
|-- application/
|-- services/
|-- ProductService.ts
|-- infrastructure/
|-- repositories/
|-- InMemoryProductRepository.ts
|-- presentation/
|-- ProductController.ts
|-- user/
|-- domain/
|-- entities/
|-- User.ts
|-- interfaces/
|-- UserRepository.ts
|-- application/
|-- services/
|-- UserService.ts
|-- infrastructure/
|-- repositories/
|-- InMemoryUserRepository.ts
|-- presentation/
|-- UserController.ts
|-- shared/
|-- core/
|-- domain/
|-- events/
|-- DomainEvent.ts
|-- infrastructure/
|-- logging/
|-- Logger.ts
|-- index.ts
2. Domain Layer
The Domain Layer is the core of the Onion Architecture, responsible for the business logic.
Product Entity
// src/modules/product/domain/entities/Product.ts
export class Product {
constructor(public id: string, public name: string, public price: number) {}
}
Explanation:
- This is the Product entity, representing the business concept of a product.
- It contains three properties:
-
id
: A unique identifier for the product. -
name
: The product's name. -
price
: The price of the product.
-
- Entities should only include business rules and avoid dependencies on other layers.
Product Repository Interface
// src/modules/product/domain/interfaces/ProductRepository.ts
import { Product } from "../entities/Product";
export interface ProductRepository {
getAll(): Promise<Product[]>;
save(product: Product): Promise<void>;
}
Explanation:
- This is the repository interface, defining the contract for data persistence.
- It includes:
-
getAll()
: Fetches all products. -
save(product)
: Saves a product.
-
- The domain layer defines the interface but doesn't implement it. Implementation is delegated to the infrastructure layer, ensuring the core is independent of external systems.
User Entity
// src/modules/user/domain/entities/User.ts
export class User {
constructor(public id: string, public name: string, public email: string) {}
}
Explanation:
- Similar to
Product
, this is the User entity. It represents users in the system with three fields:-
id
: A unique identifier. -
name
: The user's name. -
email
: The user's email address.
-
- The focus here is business rules only, avoiding any database or framework dependencies.
User Repository Interface
// src/modules/user/domain/interfaces/UserRepository.ts
import { User } from "../entities/User";
export interface UserRepository {
getAll(): Promise<User[]>;
save(user: User): Promise<void>;
}
Explanation:
- This is the repository interface for the User module. It defines:
-
getAll()
: Fetch all users. -
save(user)
: Save a user.
-
- Just like
ProductRepository
, it is implemented in the infrastructure layer to keep the domain clean.
3. Application Layer
The Application Layer coordinates workflows and business rules. It relies on the domain interfaces and serves as a bridge between the presentation and domain layers.
Product Service
// src/modules/product/application/services/ProductService.ts
import { Product } from "../../domain/entities/Product";
import { ProductRepository } from "../../domain/interfaces/ProductRepository";
export class ProductService {
constructor(private productRepository: ProductRepository) {}
async listProducts(): Promise<Product[]> {
return this.productRepository.getAll();
}
async addProduct(name: string, price: number): Promise<void> {
const product = new Product(Date.now().toString(), name, price);
await this.productRepository.save(product);
}
}
Explanation:
-
Purpose:
ProductService
contains the application logic for managing products. -
Methods:
-
listProducts()
: Fetches all products from the repository. -
addProduct(name, price)
: Creates a newProduct
entity and saves it using the repository.
-
-
Dependency Injection: The
ProductRepository
interface is injected into the service, allowing flexibility to switch repository implementations.
User Service
// src/modules/user/application/services/UserService.ts
import { User } from "../../domain/entities/User";
import { UserRepository } from "../../domain/interfaces/UserRepository";
export class UserService {
constructor(private userRepository: UserRepository) {}
async listUsers(): Promise<User[]> {
return this.userRepository.getAll();
}
async addUser(name: string, email: string): Promise<void> {
const user = new User(Date.now().toString(), name, email);
await this.userRepository.save(user);
}
}
Explanation:
-
Purpose:
UserService
contains the workflows for user management. -
Methods:
-
listUsers()
: Retrieves all users. -
addUser(name, email)
: Creates aUser
entity and saves it.
-
- The logic is simple but reusable, with repository dependencies abstracted away.
4. Infrastructure Layer
The Infrastructure Layer implements the domain-defined interfaces, handling database or API interactions.
In-Memory Product Repository
// src/modules/product/infrastructure/repositories/InMemoryProductRepository.ts
import { Product } from "../../domain/entities/Product";
import { ProductRepository } from "../../domain/interfaces/ProductRepository";
export class InMemoryProductRepository implements ProductRepository {
private products: Product[] = [];
async getAll(): Promise<Product[]> {
return this.products;
}
async save(product: Product): Promise<void> {
this.products.push(product);
}
}
Explanation:
- Implements the
ProductRepository
interface. -
products
is an in-memory array for storing data (useful for testing or small-scale apps). - Handles persistence methods (
getAll
,save
) while insulating the domain from storage details.
In-Memory User Repository
// src/modules/user/infrastructure/repositories/InMemoryUserRepository.ts
import { User } from "../../domain/entities/User";
import { UserRepository } from "../../domain/interfaces/UserRepository";
export class InMemoryUserRepository implements UserRepository {
private users: User[] = [];
async getAll(): Promise<User[]> {
return this.users;
}
async save(user: User): Promise<void> {
this.users.push(user);
}
}
Explanation:
- Similar to
InMemoryProductRepository
, this repository stores users in memory. - Implements the methods defined in
UserRepository
.
5. Presentation Layer
The Presentation Layer provides an interface (HTTP APIs) for interacting with the system.
Product Controller
// src/modules/product/presentation/ProductController.ts
import express, { Request, Response } from "express";
import { ProductService } from "../application/services/ProductService";
import { InMemoryProductRepository } from "../infrastructure/repositories/InMemoryProductRepository";
const router = express.Router();
const productRepository = new InMemoryProductRepository();
const productService = new ProductService(productRepository);
router.get("/", async (req: Request, res: Response) => {
const products = await productService.listProducts();
res.json(products);
});
router.post("/", async (req: Request, res: Response) => {
const { name, price } = req.body;
await productService.addProduct(name, price);
res.status(201).send();
});
export default router;
Explanation:
-
Routes:
-
GET /
: Fetches all products by callingProductService.listProducts
. -
POST /
: Adds a new product usingProductService.addProduct
.
-
- The controller bridges the presentation and application layers.
User Controller
// src/modules/user/presentation/UserController.ts
import express, { Request, Response } from "express";
import { UserService } from "../application/services/UserService";
import { InMemoryUserRepository } from "../infrastructure/repositories/InMemoryUserRepository";
const router = express.Router();
const userRepository = new InMemoryUserRepository();
const userService = new UserService(userRepository);
router.get("/", async (req: Request, res: Response) => {
const users = await userService.listUsers();
res.json(users);
});
router.post("/", async (req: Request, res: Response) => {
const { name, email } = req.body;
await userService.addUser(name, email);
res.status(201).send();
});
export default router;
Explanation:
-
Routes:
-
GET /
: Retrieves all users from the user service. -
POST /
: Adds a user usingUserService.addUser
.
-
6. Main Application
// src/index.ts
import express from "express";
import bodyParser from "body-parser";
import productRoutes from "./modules/product/presentation/ProductController";
import userRoutes from "./modules/user/presentation/UserController";
const app = express();
app.use(bodyParser.json());
app.use("/api/products", productRoutes);
app.use("/api/users", userRoutes);
const PORT = 3000;
app.listen(PORT, () => console.log(`Server running on http://localhost:${PORT}`));
Explanation:
- Sets up Express with routes for products and users.
- Starts the server on port 3000.
7. Testing the Onion Architecture Implementation
To test the implementation, we can use tools like Postman, cURL, or write automated tests using Jest (or any preferred testing library).
A. Manual Testing with Postman or cURL
1. Start the Server
Run the application:
npm run dev
The server will start at http://localhost:3000
.
2. Test Product APIs
Endpoint 1: Add a Product
-
Method:
POST
-
URL:
http://localhost:3000/api/products
-
Body:
{ "name": "Laptop", "price": 1500 }
-
Expected Response:
- Status:
201 Created
- Empty body or success message.
- Status:
Endpoint 2: List Products
-
Method:
GET
-
URL:
http://localhost:3000/api/products
-
Expected Response:
[ { "id": "1690967264173", "name": "Laptop", "price": 1500 } ]
3. Test User APIs
Endpoint 1: Add a User
-
Method:
POST
-
URL:
http://localhost:3000/api/users
-
Body:
{ "name": "John Doe", "email": "john@example.com" }
-
Expected Response:
- Status:
201 Created
- Empty body or success message.
- Status:
Endpoint 2: List Users
-
Method:
GET
-
URL:
http://localhost:3000/api/users
-
Expected Response:
[ { "id": "1690967264174", "name": "John Doe", "email": "john@example.com" } ]
B. Automated Testing with Jest
1. Setup Jest
Install Jest and related dependencies:
npm install --save-dev jest @types/jest ts-jest
npx ts-jest config:init
Update package.json
to include a test script:
"scripts": {
"test": "jest"
}
2. Write Tests
Test File for Product Service
// src/modules/product/application/services/ProductService.test.ts
import { ProductService } from "./ProductService";
import { InMemoryProductRepository } from "../../infrastructure/repositories/InMemoryProductRepository";
describe("ProductService", () => {
let productService: ProductService;
beforeEach(() => {
const productRepository = new InMemoryProductRepository();
productService = new ProductService(productRepository);
});
it("should add a product", async () => {
await productService.addProduct("Laptop", 1500);
const products = await productService.listProducts();
expect(products).toHaveLength(1);
expect(products[0]).toMatchObject({ name: "Laptop", price: 1500 });
});
it("should list all products", async () => {
await productService.addProduct("Laptop", 1500);
await productService.addProduct("Phone", 800);
const products = await productService.listProducts();
expect(products).toHaveLength(2);
});
});
Test File for User Service
// src/modules/user/application/services/UserService.test.ts
import { UserService } from "./UserService";
import { InMemoryUserRepository } from "../../infrastructure/repositories/InMemoryUserRepository";
describe("UserService", () => {
let userService: UserService;
beforeEach(() => {
const userRepository = new InMemoryUserRepository();
userService = new UserService(userRepository);
});
it("should add a user", async () => {
await userService.addUser("John Doe", "john@example.com");
const users = await userService.listUsers();
expect(users).toHaveLength(1);
expect(users[0]).toMatchObject({ name: "John Doe", email: "john@example.com" });
});
it("should list all users", async () => {
await userService.addUser("John Doe", "john@example.com");
await userService.addUser("Jane Smith", "jane@example.com");
const users = await userService.listUsers();
expect(users).toHaveLength(2);
});
});
3. Run the Tests
Execute the tests:
npm test
Sample Output:
PASS src/modules/product/application/services/ProductService.test.ts
PASS src/modules/user/application/services/UserService.test.ts
Test Suites: 2 passed, 2 total
Tests: 4 passed, 4 total
Snapshots: 0 total
Time: 1.345s
C. Benefits of Testing
-
Manual Testing:
- Helps validate the end-to-end flow using real API calls.
-
Automated Testing:
- Ensures application logic works as expected.
- Catches regressions during future changes.
By integrating both manual and automated testing, the implementation is robust, ensuring the modular Onion Architecture works seamlessly in production and under evolving requirements.
Conclusion
Onion Architecture is a powerful pattern for building scalable, maintainable, and testable applications. By aligning with DDD principles, it ensures the domain model remains central to the system. Here’s a quick recap:
- Domain Layer: Represents the core business rules.
- Application Layer: Orchestrates use cases and workflows.
- Infrastructure Layer: Implements technical details.
- Presentation Layer: Interfaces with users or external systems.
By adopting Onion Architecture, you create systems that are future-proof, as the core domain remains insulated from external changes. Start small, refine as you go, and embrace the clarity that this architecture provides!
Top comments (0)