Architecture

Software Architecture: Patterns and Best Practices

·5 min read
Software Architecture: Patterns and Best Practices

Software Architecture: Patterns and Best Practices

Software architecture is fundamental to building maintainable and scalable applications. Let's explore essential patterns and principles for effective software architecture.

Clean Architecture

1. Layer Separation

Implement clean architecture layers:

// src/domain/entities/user.entity.ts
export class User {
  constructor(
    private readonly id: string,
    private email: string,
    private name: string,
    private readonly createdAt: Date
  ) {}

  updateEmail(email: string): void {
    if (!this.isValidEmail(email)) {
      throw new Error("Invalid email");
    }
    this.email = email;
  }

  private isValidEmail(email: string): boolean {
    return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
  }
}

// src/domain/repositories/user.repository.ts
export interface UserRepository {
  findById(id: string): Promise<User | null>;
  save(user: User): Promise<void>;
  delete(id: string): Promise<void>;
}

// src/application/use-cases/update-user-email.ts
export class UpdateUserEmail {
  constructor(private readonly userRepository: UserRepository) {}

  async execute(userId: string, newEmail: string): Promise<void> {
    const user = await this.userRepository.findById(userId);
    if (!user) {
      throw new Error("User not found");
    }

    user.updateEmail(newEmail);
    await this.userRepository.save(user);
  }
}

// src/infrastructure/persistence/postgres-user.repository.ts
export class PostgresUserRepository implements UserRepository {
  constructor(private readonly db: Database) {}

  async findById(id: string): Promise<User | null> {
    const result = await this.db.query("SELECT * FROM users WHERE id = $1", [
      id,
    ]);

    if (!result.rows[0]) return null;

    return new User(
      result.rows[0].id,
      result.rows[0].email,
      result.rows[0].name,
      result.rows[0].created_at
    );
  }

  async save(user: User): Promise<void> {
    await this.db.query("UPDATE users SET email = $1 WHERE id = $2", [
      user.email,
      user.id,
    ]);
  }
}

Domain-Driven Design

1. Aggregate Roots

Implement DDD aggregates:

// src/domain/aggregates/order.aggregate.ts
class OrderItem {
  constructor(
    private readonly productId: string,
    private quantity: number,
    private readonly price: Money
  ) {
    if (quantity <= 0) {
      throw new Error("Quantity must be positive");
    }
  }

  getTotal(): Money {
    return this.price.multiply(this.quantity);
  }
}

class Order {
  private items: OrderItem[] = [];
  private status: OrderStatus;

  constructor(
    private readonly id: string,
    private readonly customerId: string
  ) {
    this.status = OrderStatus.DRAFT;
  }

  addItem(productId: string, quantity: number, price: Money): void {
    if (this.status !== OrderStatus.DRAFT) {
      throw new Error("Cannot modify confirmed order");
    }

    const item = new OrderItem(productId, quantity, price);
    this.items.push(item);
  }

  confirm(): void {
    if (this.items.length === 0) {
      throw new Error("Cannot confirm empty order");
    }

    this.status = OrderStatus.CONFIRMED;
  }

  getTotal(): Money {
    return this.items.reduce(
      (total, item) => total.add(item.getTotal()),
      Money.zero()
    );
  }
}

// src/domain/value-objects/money.ts
class Money {
  private constructor(
    private readonly amount: number,
    private readonly currency: string
  ) {
    if (amount < 0) {
      throw new Error("Amount cannot be negative");
    }
  }

  static zero(): Money {
    return new Money(0, "USD");
  }

  static of(amount: number, currency: string): Money {
    return new Money(amount, currency);
  }

  add(other: Money): Money {
    if (this.currency !== other.currency) {
      throw new Error("Cannot add different currencies");
    }
    return new Money(this.amount + other.amount, this.currency);
  }

  multiply(factor: number): Money {
    return new Money(this.amount * factor, this.currency);
  }
}

SOLID Principles

1. Dependency Inversion

Implement dependency inversion:

// src/domain/services/notification.service.ts
interface NotificationService {
  send(recipient: string, subject: string, content: string): Promise<void>;
}

// src/infrastructure/services/email-notification.service.ts
class EmailNotificationService implements NotificationService {
  constructor(private readonly emailClient: EmailClient) {}

  async send(
    recipient: string,
    subject: string,
    content: string
  ): Promise<void> {
    await this.emailClient.sendEmail({
      to: recipient,
      subject,
      html: content,
    });
  }
}

// src/infrastructure/services/sms-notification.service.ts
class SMSNotificationService implements NotificationService {
  constructor(private readonly smsClient: SMSClient) {}

  async send(
    recipient: string,
    subject: string,
    content: string
  ): Promise<void> {
    await this.smsClient.sendMessage(recipient, `${subject}: ${content}`);
  }
}

// src/application/services/user-notification.service.ts
class UserNotificationService {
  constructor(private readonly notificationService: NotificationService) {}

  async notifyPasswordChange(user: User): Promise<void> {
    await this.notificationService.send(
      user.email,
      "Password Changed",
      "Your password has been changed successfully."
    );
  }
}

Event-Driven Architecture

1. Domain Events

Implement domain events:

// src/domain/events/domain-event.ts
interface DomainEvent {
  occurredOn: Date;
}

// src/domain/events/user-events.ts
class UserCreatedEvent implements DomainEvent {
  constructor(
    public readonly userId: string,
    public readonly email: string,
    public readonly occurredOn: Date = new Date()
  ) {}
}

class UserEmailChangedEvent implements DomainEvent {
  constructor(
    public readonly userId: string,
    public readonly oldEmail: string,
    public readonly newEmail: string,
    public readonly occurredOn: Date = new Date()
  ) {}
}

// src/domain/aggregates/user.aggregate.ts
class User {
  private events: DomainEvent[] = [];

  constructor(private readonly id: string, private email: string) {
    this.events.push(new UserCreatedEvent(id, email));
  }

  changeEmail(newEmail: string): void {
    const oldEmail = this.email;
    this.email = newEmail;

    this.events.push(new UserEmailChangedEvent(this.id, oldEmail, newEmail));
  }

  getUncommittedEvents(): DomainEvent[] {
    return [...this.events];
  }

  clearEvents(): void {
    this.events = [];
  }
}

// src/infrastructure/event-handlers/user-event.handler.ts
class UserEventHandler {
  constructor(
    private readonly notificationService: NotificationService,
    private readonly auditLogger: AuditLogger
  ) {}

  async handle(event: DomainEvent): Promise<void> {
    if (event instanceof UserCreatedEvent) {
      await this.handleUserCreated(event);
    } else if (event instanceof UserEmailChangedEvent) {
      await this.handleUserEmailChanged(event);
    }
  }

  private async handleUserCreated(event: UserCreatedEvent): Promise<void> {
    await this.notificationService.send(
      event.email,
      "Welcome!",
      "Welcome to our platform."
    );

    await this.auditLogger.log("User created", { userId: event.userId });
  }

  private async handleUserEmailChanged(
    event: UserEmailChangedEvent
  ): Promise<void> {
    await this.notificationService.send(
      event.newEmail,
      "Email Changed",
      "Your email has been updated successfully."
    );

    await this.auditLogger.log("User email changed", {
      userId: event.userId,
      oldEmail: event.oldEmail,
      newEmail: event.newEmail,
    });
  }
}

Best Practices

  1. Separation of Concerns: Keep layers and modules separate
  2. Domain-Driven Design: Focus on the business domain
  3. SOLID Principles: Follow SOLID design principles
  4. Clean Architecture: Implement clean architecture patterns
  5. Event-Driven Design: Use events for loose coupling
  6. Testing: Write comprehensive tests
  7. Documentation: Maintain architecture documentation
  8. Continuous Refactoring: Regularly refactor and improve

Implementation Checklist

  1. Define domain model
  2. Implement clean architecture
  3. Set up DDD patterns
  4. Configure event handling
  5. Implement SOLID principles
  6. Set up testing strategy
  7. Document architecture
  8. Configure monitoring

Conclusion

Effective software architecture is crucial for building maintainable applications. Focus on implementing these patterns and principles consistently.

Resources