阿里云主机折上折
  • 微信号
Current Site:Index > Preliminary Application of Domain-Driven Design

Preliminary Application of Domain-Driven Design

Author:Chuan Chen 阅读数:11612人阅读 分类: Node.js

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:

  1. Business logic is scattered across controllers and service layers.
  2. Domain concepts are unclear, making code difficult to understand.
  3. 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:

  1. Avoid creating too many instances in domain objects.
  2. Consider using the Data Mapper pattern to reduce memory usage.
  3. 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:

  1. First identify core domain objects.
  2. Move business logic from controllers to domain classes.
  3. Gradually introduce the repository pattern.
  4. 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:

  1. Use business terminology in code naming.
  2. Keep domain models separate from database models.
  3. 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

Front End Chuan

Front End Chuan, Chen Chuan's Code Teahouse 🍵, specializing in exorcising all kinds of stubborn bugs 💻. Daily serving baldness-warning-level development insights 🛠️, with a bonus of one-liners that'll make you laugh for ten years 🐟. Occasionally drops pixel-perfect romance brewed in a coffee cup ☕.