Skip to main content

Architecture Design

building_blocks

Software architecture is the fundamental organization of a system, encompassing its components, relationships, and principles guiding its design and evolution. Good architecture design enables systems to be scalable, maintainable, testable, and resilient while meeting business requirements and technical constraints.

Architecture decisions have far-reaching implications—they affect development velocity, system reliability, operational costs, and the ability to adapt to changing requirements. This chapter explores key principles, patterns, and practices for designing effective software architectures.

Architectural Principles

Separation of Concerns

Divide your system into distinct sections, each addressing a specific concern or responsibility. This principle reduces complexity and improves maintainability.

Benefits:

  • Easier to understand and modify individual components
  • Reduces coupling between different parts of the system
  • Enables parallel development by different teams
  • Facilitates testing and debugging

Single Responsibility Principle (SRP)

Each component, module, or class should have one reason to change—it should have a single, well-defined responsibility.

Bad Example:

class UserManager {
createUser(userData) {
// Validates user data
if (!userData.email || !userData.password) {
throw new Error('Invalid user data');
}

// Saves to database
db.users.insert(userData);

// Sends welcome email
emailService.send(userData.email, 'Welcome!');

// Logs the action
logger.info(`User created: ${userData.email}`);
}
}

Good Example:

class UserValidator {
validate(userData) {
if (!userData.email || !userData.password) {
throw new Error('Invalid user data');
}
}
}

class UserRepository {
create(userData) {
return db.users.insert(userData);
}
}

class UserNotificationService {
sendWelcomeEmail(email) {
emailService.send(email, 'Welcome!');
}
}

class UserService {
constructor(validator, repository, notificationService, logger) {
this.validator = validator;
this.repository = repository;
this.notificationService = notificationService;
this.logger = logger;
}

async createUser(userData) {
this.validator.validate(userData);
const user = await this.repository.create(userData);
await this.notificationService.sendWelcomeEmail(user.email);
this.logger.info(`User created: ${user.email}`);
return user;
}
}

Loose Coupling and High Cohesion

Loose Coupling: Minimize dependencies between components. Components should interact through well-defined interfaces rather than direct implementation dependencies.

High Cohesion: Elements within a component should be closely related and work together toward a common purpose.

Don't Repeat Yourself (DRY)

Avoid duplicating code or logic. Extract common functionality into reusable components or utilities.

Warning: Don't over-apply DRY. Sometimes similar-looking code serves different purposes or will evolve differently. Premature abstraction can create unnecessary coupling.

YAGNI (You Aren't Gonna Need It)

Don't build features or abstractions until they're actually needed. Over-engineering leads to unnecessary complexity and maintenance burden.

Open/Closed Principle

Software entities should be open for extension but closed for modification. Design systems that can be extended without changing existing code.

// Bad: Adding new payment methods requires modifying existing code
class PaymentProcessor {
processPayment(paymentType, amount) {
if (paymentType === 'credit_card') {
// Process credit card
} else if (paymentType === 'paypal') {
// Process PayPal
} else if (paymentType === 'crypto') {
// Process crypto - requires modifying this function
}
}
}

// Good: New payment methods can be added without modifying existing code
class PaymentProcessor {
constructor() {
this.paymentMethods = new Map();
}

registerPaymentMethod(type, handler) {
this.paymentMethods.set(type, handler);
}

processPayment(paymentType, amount) {
const handler = this.paymentMethods.get(paymentType);
if (!handler) {
throw new Error(`Unknown payment type: ${paymentType}`);
}
return handler.process(amount);
}
}

// Usage
const processor = new PaymentProcessor();
processor.registerPaymentMethod('credit_card', new CreditCardHandler());
processor.registerPaymentMethod('paypal', new PayPalHandler());
processor.registerPaymentMethod('crypto', new CryptoHandler()); // No modification needed

Architectural Patterns

Layered Architecture

Organizes the system into horizontal layers, each providing services to the layer above and consuming services from the layer below.

When to Use:

  • Traditional web applications
  • Enterprise applications with clear separation of concerns
  • Teams organized by technical expertise (frontend, backend, database)

Pros:

  • Simple and well-understood
  • Clear separation of concerns
  • Easy to test individual layers

Cons:

  • Can lead to "architecture sinkhole" (requests passing through layers without processing)
  • May become monolithic and difficult to scale
  • Changes often require modifications across multiple layers

Microservices Architecture

Structures the application as a collection of loosely coupled, independently deployable services.

When to Use:

  • Large, complex applications requiring independent scaling
  • Organizations with multiple autonomous teams
  • Need for technology diversity across services
  • Frequent deployments and updates

Pros:

  • Independent deployment and scaling
  • Technology flexibility per service
  • Fault isolation
  • Easier to understand individual services

Cons:

  • Increased operational complexity
  • Distributed system challenges (network latency, partial failures)
  • Data consistency across services
  • Testing complexity

Key Considerations:

  • Define clear service boundaries based on business capabilities
  • Implement proper inter-service communication (REST, gRPC, message queues)
  • Plan for distributed tracing and monitoring
  • Address data management and eventual consistency

Event-Driven Architecture

Components communicate through events rather than direct calls. Services publish events when state changes occur, and other services subscribe to relevant events.

When to Use:

  • Systems requiring high scalability
  • Complex workflows with multiple steps
  • Real-time data processing
  • Decoupling between services is critical

Pros:

  • Loose coupling between components
  • Easy to add new subscribers without modifying publishers
  • Natural support for asynchronous processing
  • Excellent scalability

Cons:

  • Harder to understand overall system flow
  • Debugging distributed events is challenging
  • Eventual consistency requires careful handling
  • Potential for message ordering issues

Event Types:

  • Domain Events: Business-significant occurrences (OrderPlaced, UserRegistered)
  • Integration Events: Cross-service communication events
  • Command Events: Trigger specific actions

Hexagonal Architecture (Ports and Adapters)

Isolates the core business logic from external concerns by defining clear boundaries through ports (interfaces) and adapters (implementations).

When to Use:

  • Applications requiring high testability
  • Systems that may need to swap infrastructure components
  • Domain-driven design implementations
  • Long-lived applications requiring flexibility

Pros:

  • Business logic independent of frameworks and infrastructure
  • Highly testable (can mock all external dependencies)
  • Flexible to technology changes
  • Clear separation of concerns

Cons:

  • More upfront design effort
  • Can feel over-engineered for simple applications
  • Requires discipline to maintain boundaries

Clean Architecture

Similar to Hexagonal Architecture, Clean Architecture emphasizes independence of frameworks, UI, database, and external agencies.

Dependency Rule: Dependencies can only point inward. Inner circles know nothing about outer circles.

CQRS (Command Query Responsibility Segregation)

Separates read and write operations into different models, optimizing each for its specific purpose.

When to Use:

  • Complex domains with different read and write requirements
  • High-performance read requirements
  • Event sourcing implementations
  • Different scalability needs for reads and writes

Pros:

  • Optimized read and write models
  • Independent scaling of reads and writes
  • Simplified queries
  • Natural fit with event-driven architectures

Cons:

  • Increased complexity
  • Eventual consistency between read and write models
  • More infrastructure components to manage

Making Architectural Decisions

Decision-Making Framework

Key Considerations

When making architectural decisions, evaluate:

  1. Functional Requirements

    • What features must the system support?
    • What are the business capabilities needed?
  2. Non-Functional Requirements

    • Performance: Response time, throughput, latency requirements
    • Scalability: Expected growth, concurrent users, data volume
    • Availability: Uptime requirements, disaster recovery
    • Security: Authentication, authorization, data protection
    • Maintainability: Code quality, documentation, testability
    • Reliability: Fault tolerance, error handling
    • Observability: Logging, monitoring, tracing
  3. Technical Constraints

    • Existing systems and integrations
    • Technology stack and team expertise
    • Infrastructure and budget limitations
    • Compliance and regulatory requirements
  4. Organizational Factors

    • Team size and structure
    • Development velocity requirements
    • Deployment frequency
    • Support and operations capabilities

Architecture Decision Records (ADRs)

Document significant architectural decisions using ADRs. See the Architecture Decision Records chapter for detailed guidance.

Quick Template:

# ADR-001: Use Microservices Architecture

## Status

Accepted

## Context

Our monolithic application is becoming difficult to scale and deploy. Multiple teams are blocked by deployment
conflicts.

## Decision

We will adopt a microservices architecture, splitting the application into independently deployable services organized
by business capability.

## Consequences

Positive:

- Independent deployment and scaling
- Team autonomy
- Technology flexibility

Negative:

- Increased operational complexity
- Need for distributed tracing
- Data consistency challenges

## Alternatives Considered

- Modular monolith
- Service-oriented architecture (SOA)

Designing for Scalability

Horizontal vs. Vertical Scaling

Vertical Scaling (Scale Up):

  • Add more resources (CPU, RAM) to existing servers
  • Simpler to implement
  • Limited by hardware constraints
  • Single point of failure

Horizontal Scaling (Scale Out):

  • Add more servers to distribute load
  • Better fault tolerance
  • Requires stateless design
  • More complex infrastructure

Caching Strategies

Implement caching at multiple levels to improve performance:

Caching Patterns:

  • Cache-Aside: Application checks cache, loads from database on miss
  • Read-Through: Cache automatically loads from database on miss
  • Write-Through: Writes go through cache to database
  • Write-Behind: Writes batched and asynchronously written to database

Database Scaling

Designing for Resilience

Failure Handling Patterns

Circuit Breaker Pattern

Prevents cascading failures by detecting failures and stopping requests to failing services.

Implementation Example:

class CircuitBreaker {
constructor(failureThreshold = 5, timeout = 60000) {
this.failureThreshold = failureThreshold;
this.timeout = timeout;
this.failureCount = 0;
this.state = 'CLOSED';
this.nextAttempt = Date.now();
}

async execute(operation) {
if (this.state === 'OPEN') {
if (Date.now() < this.nextAttempt) {
throw new Error('Circuit breaker is OPEN');
}
this.state = 'HALF_OPEN';
}

try {
const result = await operation();
this.onSuccess();
return result;
} catch (error) {
this.onFailure();
throw error;
}
}

onSuccess() {
this.failureCount = 0;
this.state = 'CLOSED';
}

onFailure() {
this.failureCount++;
if (this.failureCount >= this.failureThreshold) {
this.state = 'OPEN';
this.nextAttempt = Date.now() + this.timeout;
}
}
}

// Usage
const breaker = new CircuitBreaker();

try {
const result = await breaker.execute(() => externalService.call());
} catch (error) {
// Handle failure or use fallback
return fallbackResponse;
}

Bulkhead Pattern

Isolate resources to prevent failures in one part from affecting others.

If the Payment Service becomes slow or unresponsive, it only affects Thread Pool 1, leaving other services operational.

Security Considerations

Defense in Depth

Implement security at multiple layers:

Security Principles

  1. Principle of Least Privilege: Grant minimum necessary permissions
  2. Zero Trust: Never trust, always verify
  3. Fail Securely: Default to secure state on failure
  4. Secure by Default: Security should be the default configuration
  5. Defense in Depth: Multiple layers of security controls
  6. Input Validation: Validate and sanitize all inputs
  7. Encryption: Encrypt data at rest and in transit

Authentication and Authorization Flow

Performance Optimization

Asynchronous Processing

Offload long-running tasks to background workers to improve response times.

Benefits:

  • Faster API response times
  • Better resource utilization
  • Fault tolerance (retry failed jobs)
  • Scalable processing

Content Delivery Network (CDN)

Monitoring and Observability

Build systems that are easy to understand and debug in production.

Three Pillars of Observability

See the Logging chapter for detailed guidance on logging best practices.

Distributed Tracing

Track requests across multiple services to understand system behavior and identify bottlenecks.

Testing Architecture

Architecture Testing Tools:

  • ArchUnit (Java): Enforce architectural rules as unit tests
  • dependency-cruiser (JavaScript): Validate dependency rules
  • pytest-archon (Python): Architecture testing for Python

Example Architecture Test:

// Test that services don't depend on infrastructure directly
describe('Architecture Rules', () => {
it('should ensure services do not import from infrastructure layer', () => {
const services = getFilesInDirectory('src/services');
const imports = getAllImports(services);

imports.forEach((importPath) => {
expect(importPath).not.toMatch(/infrastructure/);
});
});

it('should ensure core domain has no external dependencies', () => {
const domainFiles = getFilesInDirectory('src/domain');
const imports = getAllImports(domainFiles);

imports.forEach((importPath) => {
expect(importPath).toMatch(/^\.\.?\/domain/);
});
});
});

Common Anti-Patterns

Big Ball of Mud

A system lacking clear architecture or structure, with tangled dependencies and unclear boundaries.

Signs:

  • No clear separation of concerns
  • High coupling between components
  • Difficult to understand or modify
  • "Change one thing, break everything else"

Prevention:

  • Define clear architectural boundaries
  • Enforce dependency rules
  • Regular refactoring
  • Code reviews focusing on architecture

Premature Optimization

Optimizing code before understanding actual performance bottlenecks.

Better Approach:

  1. Make it work
  2. Make it right (refactor)
  3. Make it fast (if needed, based on measurements)

God Object

A single class or module that knows too much or does too much.

Solution: Apply Single Responsibility Principle, extract cohesive components.

Lava Flow

Dead code that remains in the codebase because no one dares to remove it.

Prevention:

  • Version control gives you safety to delete code
  • Remove unused code immediately
  • Document temporary workarounds with TODOs and expiration dates

Golden Hammer

Using the same solution for every problem ("If all you have is a hammer, everything looks like a nail").

Solution: Evaluate each problem on its merits, choose appropriate tools and patterns.

Migration Strategies

Strangler Fig Pattern

Gradually replace a legacy system by building new functionality around it and slowly deprecating old components.

Benefits:

  • Low risk (gradual migration)
  • Continuous delivery of value
  • Can pause or rollback if issues arise

Branch by Abstraction

Introduce an abstraction layer, create new implementation behind it, then switch over.

  1. Create abstraction layer
  2. Migrate existing code to use abstraction
  3. Create new implementation
  4. Switch abstraction to point to new implementation
  5. Remove old implementation

Documentation

Good architecture documentation helps teams understand and maintain systems.

C4 Model

Document architecture at multiple levels of abstraction:

  1. Context: System and its external dependencies
  2. Container: High-level technology choices
  3. Component: Components within containers
  4. Code: Class diagrams (optional)

Essential Documentation

  • System Overview: Purpose, key features, high-level architecture
  • Architecture Diagrams: Visual representations of system structure
  • Component Documentation: Responsibilities, interfaces, dependencies
  • ADRs: Record of significant decisions
  • Deployment Architecture: Infrastructure, environments, deployment process
  • Data Models: Database schemas, data flows
  • API Documentation: Endpoints, contracts, examples
  • Operational Runbooks: Monitoring, troubleshooting, disaster recovery

See the Documentation chapter for comprehensive guidance.

Key Takeaways

  1. Start Simple: Don't over-engineer. Begin with the simplest architecture that could work, then evolve based on actual needs.

  2. Document Decisions: Use Architecture Decision Records to capture the context and rationale behind significant choices.

  3. Design for Change: Requirements evolve. Build flexibility into your architecture where it matters most.

  4. Embrace Trade-offs: Every architectural decision involves trade-offs. Make them explicit and choose based on your specific context.

  5. Measure and Monitor: Build observability into your architecture from the start. You can't improve what you can't measure.

  6. Automate Quality: Use architecture tests, linting, and CI/CD to enforce architectural rules automatically.

  7. Think About Failure: Design for resilience. Consider what happens when components fail and build in appropriate safeguards.

  8. Optimize for Team: Consider your team's size, skills, and organization when choosing architectural patterns.

  9. Prioritize Security: Build security into every layer rather than adding it as an afterthought.

  10. Keep Learning: Software architecture is constantly evolving. Stay current with patterns, practices, and technologies.