Preliminary Application of Domain-Driven Design
Initial Application of Domain-Driven Design
Domain-Driven Design (DDD) is a software development approach that emphasizes building complex systems through a deep understanding of the business domain. Applying DDD in the Koa2 framework can help us better organize code structure and improve maintainability and scalability.
Why Use DDD in Koa2
Koa2, as a lightweight Node.js framework, does not enforce any specific architectural pattern. However, as business complexity grows, traditional MVC architecture may become inadequate:
- Business logic is scattered across controllers and service layers.
- Domain concepts are unclear, making code difficult to understand.
- Modifying one part may affect multiple unrelated features.
// Traditional Koa2 controller example
router.post('/orders', async (ctx) => {
const { userId, productId } = ctx.request.body;
// Validate user
const user = await userService.findById(userId);
if (!user) throw new Error('User does not exist');
// Validate product
const product = await productService.findById(productId);
if (!product.stock <= 0) throw new Error('Insufficient stock');
// Create order
const order = await orderService.create({
userId,
productId,
status: 'pending'
});
ctx.body = order;
});
This approach places all business logic in the controller, making it difficult to maintain as features grow.
Implementing DDD Core Concepts in Koa2
Entity
Entities are domain objects with unique identifiers. In an order system, Order
is a typical entity:
// domain/order.js
class Order {
constructor({ id, userId, productId, status }) {
this._id = id;
this._userId = userId;
this._productId = productId;
this._status = status;
}
get id() { return this._id; }
get status() { return this._status; }
cancel() {
if (this._status !== 'pending') {
throw new Error('Only pending orders can be canceled');
}
this._status = 'cancelled';
}
toJSON() {
return {
id: this._id,
userId: this._userId,
productId: this._productId,
status: this._status
};
}
}
Value Object
Value objects have no unique identifier and are defined by their attributes:
// domain/address.js
class Address {
constructor({ province, city, district, detail }) {
this.province = province;
this.city = city;
this.district = district;
this.detail = detail;
}
equals(other) {
return this.province === other.province &&
this.city === other.city &&
this.district === other.district &&
this.detail === other.detail;
}
toString() {
return `${this.province}${this.city}${this.district}${this.detail}`;
}
}
Aggregate Root
Aggregate roots serve as entry points for external access to aggregates:
// domain/order.js
class Order {
// ...previous code
addPayment(payment) {
if (this._payments.some(p => p.id === payment.id)) {
throw new Error('Payment already exists');
}
this._payments.push(payment);
}
get totalPaid() {
return this._payments.reduce((sum, p) => sum + p.amount, 0);
}
}
Implementing Layered Architecture
Implementing typical DDD layers in Koa2:
src/
├── application/ # Application layer
├── domain/ # Domain layer
├── infrastructure/ # Infrastructure layer
└── interfaces/ # Interface layer
Application Layer Example
// application/orderService.js
class OrderService {
constructor({ orderRepository, userRepository }) {
this.orderRepository = orderRepository;
this.userRepository = userRepository;
}
async createOrder(userId, productId) {
const user = await this.userRepository.findById(userId);
if (!user) throw new Error('User does not exist');
const order = new Order({
userId,
productId,
status: 'pending'
});
await this.orderRepository.save(order);
return order;
}
}
Infrastructure Layer
// infrastructure/orderRepository.js
class OrderRepository {
constructor({ db }) {
this.db = db;
}
async findById(id) {
const data = await this.db('orders').where({ id }).first();
return data ? new Order(data) : null;
}
async save(order) {
if (order.id) {
await this.db('orders')
.where({ id: order.id })
.update(order.toJSON());
} else {
const [id] = await this.db('orders').insert(order.toJSON());
order._id = id;
}
}
}
Refactoring Koa2 Controllers
After moving business logic to the domain layer, controllers become concise:
// interfaces/controllers/orderController.js
const router = require('koa-router')();
const OrderService = require('../../application/orderService');
router.post('/orders', async (ctx) => {
const { userId, productId } = ctx.request.body;
const order = await OrderService.createOrder(userId, productId);
ctx.body = order.toJSON();
});
router.post('/orders/:id/cancel', async (ctx) => {
const order = await OrderService.cancelOrder(ctx.params.id);
ctx.body = order.toJSON();
});
Handling Complex Business Scenarios
For cross-aggregate business logic, use domain services:
// domain/services/orderPaymentService.js
class OrderPaymentService {
constructor({ orderRepository, paymentRepository }) {
this.orderRepository = orderRepository;
this.paymentRepository = paymentRepository;
}
async processPayment(orderId, paymentInfo) {
const order = await this.orderRepository.findById(orderId);
if (!order) throw new Error('Order does not exist');
const payment = new Payment(paymentInfo);
order.addPayment(payment);
if (order.totalPaid >= order.totalAmount) {
order.complete();
}
await this.orderRepository.save(order);
await this.paymentRepository.save(payment);
return { order, payment };
}
}
Integrating Event-Driven Architecture
Introducing events in the domain model:
// domain/order.js
class Order {
constructor() {
this._events = [];
}
complete() {
this._status = 'completed';
this._events.push(new OrderCompletedEvent(this));
}
get events() {
return [...this._events];
}
clearEvents() {
this._events = [];
}
}
// application/orderService.js
async function createOrder() {
// ...previous code
const order = new Order(/* ... */);
await this.orderRepository.save(order);
// Handle domain events
order.events.forEach(event => {
if (event instanceof OrderCompletedEvent) {
eventBus.emit('order.completed', event.order);
}
});
order.clearEvents();
return order;
}
Adjusting Testing Strategy
DDD makes unit tests more focused on domain logic:
// test/domain/order.test.js
describe('Order', () => {
it('should allow canceling pending orders', () => {
const order = new Order({ status: 'pending' });
order.cancel();
expect(order.status).toBe('cancelled');
});
it('should prohibit canceling non-pending orders', () => {
const order = new Order({ status: 'completed' });
expect(() => order.cancel()).toThrow('Only pending orders can be canceled');
});
});
// test/application/orderService.test.js
describe('OrderService', () => {
it('should validate user existence when creating order', async () => {
const mockUserRepo = { findById: jest.fn().mockResolvedValue(null) };
const service = new OrderService({ userRepository: mockUserRepo });
await expect(service.createOrder('invalid', 'product1'))
.rejects
.toThrow('User does not exist');
});
});
Integrating with Koa2 Middleware
Combining DDD architecture with Koa2 middleware:
// interfaces/middlewares/domainContext.js
async function domainContext(ctx, next) {
// Initialize domain services
ctx.services = {
orderService: new OrderService({
orderRepository: new OrderRepository({ db: ctx.db }),
userRepository: new UserRepository({ db: ctx.db })
})
};
await next();
}
// app.js
app.use(async (ctx, next) => {
ctx.db = getDbConnection(); // Initialize database connection
await next();
});
app.use(domainContext);
Performance Considerations
When implementing DDD in Node.js, consider:
- Avoid creating too many instances in domain objects.
- Consider using the Data Mapper pattern to reduce memory usage.
- For complex queries, use the infrastructure layer directly.
// infrastructure/orderRepository.js
class OrderRepository {
// ...other methods
async findRecentOrders(limit = 10) {
// Return POJOs instead of domain objects
return this.db('orders')
.orderBy('created_at', 'desc')
.limit(limit);
}
}
Gradual Migration from Existing Code
Steps for migrating from traditional architecture to DDD:
- First identify core domain objects.
- Move business logic from controllers to domain classes.
- Gradually introduce the repository pattern.
- Finally, handle cross-aggregate logic.
// Transitional code example during migration
class LegacyOrderService {
constructor() {
// Temporarily support both old and new approaches
this.domainService = new OrderService(/* ... */);
}
async createOrder(userId, productId) {
// Temporarily retain old logic
if (global.USE_DDD) {
return this.domainService.createOrder(userId, productId);
} else {
// Old implementation
}
}
}
Common Issues and Solutions
Issue 1: How to map domain objects to database models?
Solution: Implement mapping logic in the repository layer:
class OrderRepository {
async findById(id) {
const data = await this.db('orders').where({ id }).first();
if (!data) return null;
// Convert database model to domain object
return new Order({
id: data.id,
userId: data.user_id, // Field name conversion
productId: data.product_id,
status: data.status
});
}
}
Issue 2: How to handle complex transactions?
Use the Unit of Work pattern:
class UnitOfWork {
constructor(db) {
this.db = db;
this.repositories = new Map();
}
getRepository(repoClass) {
if (!this.repositories.has(repoClass)) {
this.repositories.set(repoClass, new repoClass(this.db));
}
return this.repositories.get(repoClass);
}
async commit() {
await this.db.transaction(async trx => {
for (const repo of this.repositories.values()) {
if (repo.commit) await repo.commit(trx);
}
});
}
}
// Usage example
const uow = new UnitOfWork(db);
const orderRepo = uow.getRepository(OrderRepository);
const order = await orderRepo.findById(1);
order.cancel();
await uow.commit();
Mapping Between Domain Models and RESTful APIs
Design APIs to reflect domain models:
GET /orders/{id} -> Order aggregate root
POST /orders/{id}/payments -> Create Payment under Order
GET /products -> Independent Product aggregate
Avoid designing endpoints that don't align with domain concepts, such as:
POST /orders/create-payment # Doesn't align; payment should belong to order
Team Collaboration and Ubiquitous Language
When practicing DDD in Koa2 projects:
- Use business terminology in code naming.
- Keep domain models separate from database models.
- Use TypeScript to enhance domain model expressiveness.
// Using TypeScript to define domain models
interface Order {
id: string;
status: 'pending' | 'completed' | 'cancelled';
cancel(): void;
}
class Order implements Order {
private status: OrderStatus;
cancel() {
if (this.status !== 'pending') {
throw new Error('Invalid status');
}
this.status = 'cancelled';
}
}
Enhancing Monitoring and Logging
Improve observability in DDD architecture:
class LoggingOrderRepository {
constructor(innerRepository, logger) {
this.inner = innerRepository;
this.logger = logger;
}
async findById(id) {
this.logger.debug('Finding order', { id });
const result = await this.inner.findById(id);
this.logger.debug('Found order', { id, exists: !!result });
return result;
}
}
// Using the decorator pattern
const orderRepo = new LoggingOrderRepository(
new OrderRepository({ db }),
logger
);
Collaborating with Frontend
Define shared domain types:
// shared-types/order.ts
export type OrderStatus =
| 'pending'
| 'processing'
| 'completed'
| 'cancelled';
export interface OrderDTO {
id: string;
userId: string;
status: OrderStatus;
createdAt: string;
}
// Both frontend and backend can use the same type definitions
Optimizing Performance-Sensitive Scenarios
For performance-critical paths, bypass the domain model:
class OrderRepository {
// ...other methods
async getOrderStatus(id) {
// Query only the required fields
const result = await this.db('orders')
.where({ id })
.select('status')
.first();
return result?.status;
}
}
Integrating with Microservices Architecture
Implementing cross-service domain interactions in Koa2:
class OrderService {
constructor({
orderRepository,
inventoryService
}) {
this.orderRepository = orderRepository;
this.inventoryService = inventoryService;
}
async createOrder(userId, productId) {
// Call inventory service
const available = await this.inventoryService.checkStock(productId);
if (!available) throw new Error('Insufficient stock');
// Create order
const order = new Order({ userId, productId });
await this.orderRepository.save(order);
// Reserve stock
await this.inventoryService.reserveStock(productId);
return order;
}
}
Domain Model Version Compatibility
Handling backward compatibility for model changes:
class Order {
constructor(data) {
// Compatible with old data versions
this._id = data.id || data._id;
this._userId = data.userId || data.user_id;
// Provide default values for new fields
this._version = data.version || 1;
}
toJSON() {
return {
id: this._id,
userId: this._userId,
version: this._version,
// Format supported by both old and new APIs
status: this._status,
_status: this._status
};
}
}
Security Considerations
Implementing security rules in domain models:
class Order {
// ...other code
canBeViewedBy(user) {
return user.isAdmin || this._userId === user.id;
}
}
// Application layer usage
async function getOrder(ctx) {
const order = await orderRepository.findById(ctx.params.id);
if (!order.canBeViewedBy(ctx.state.user)) {
ctx.status = 403;
return;
}
ctx.body = order.toJSON();
}
Integrating Domain Models with Caching
class CachedOrderRepository {
constructor(innerRepository, cache) {
this.inner = innerRepository;
this.cache = cache;
}
async findById(id) {
const cacheKey = `order:${id}`;
let order = await this.cache.get(cacheKey);
if (!order) {
order = await this.inner.findById(id);
if (order) {
await this.cache.set(cacheKey, order, { ttl: 3600 });
}
}
return order;
}
}
Integrating Domain Models with Third-Party Services
class ExternalPaymentService {
constructor(httpClient, config) {
this.http = httpClient;
this.config = config;
}
async processPayment(payment) {
const response = await this.http.post(
`${this.config.baseUrl}/payments`,
{
amount: payment.amount,
currency: payment.currency,
reference: payment.orderId
}
);
return new PaymentResult({
success: response.status === 'success',
transactionId: response.id
});
}
}
Domain Models and Data Validation
Place validation logic in domain objects:
class Email {
constructor(value) {
if (!this.validate(value)) {
throw new Error('Invalid email');
}
this.value = value;
}
validate(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
toString() {
return this.value;
}
}
class User {
constructor(email) {
this.email = new Email(email);
}
}
Practical Application of Domain Events
Implementing a complete event handling flow:
// domain/events/orderCreated.js
class OrderCreatedEvent {
constructor(order) {
this.order = order;
this.occurredOn = new Date();
}
}
// infrastructure/eventHandlers/sendOrderConfirmation.js
class SendOrderConfirmationHandler {
constructor(emailService) {
this.emailService = emailService;
}
async handle(event) {
await this.emailService.send({
to: event.order.userEmail,
subject: 'Order Confirmation',
text: `Your order #${event.order.id} has been created`
});
}
}
// Event registration
eventBus.register(
OrderCreatedEvent,
new SendOrderConfirmationHandler(emailService)
);
本站部分内容来自互联网,一切版权均归源网站或源作者所有。
如果侵犯了你的权益请来信告知我们删除。邮箱:cc@cccx.cn
上一篇:微服务架构下的 Koa2 应用
下一篇:代码组织与架构演进策略