Architecture Design
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:
-
Functional Requirements
- What features must the system support?
- What are the business capabilities needed?
-
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
-
Technical Constraints
- Existing systems and integrations
- Technology stack and team expertise
- Infrastructure and budget limitations
- Compliance and regulatory requirements
-
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
- Principle of Least Privilege: Grant minimum necessary permissions
- Zero Trust: Never trust, always verify
- Fail Securely: Default to secure state on failure
- Secure by Default: Security should be the default configuration
- Defense in Depth: Multiple layers of security controls
- Input Validation: Validate and sanitize all inputs
- 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:
- Make it work
- Make it right (refactor)
- 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.
- Create abstraction layer
- Migrate existing code to use abstraction
- Create new implementation
- Switch abstraction to point to new implementation
- Remove old implementation
Documentation
Good architecture documentation helps teams understand and maintain systems.
C4 Model
Document architecture at multiple levels of abstraction:
- Context: System and its external dependencies
- Container: High-level technology choices
- Component: Components within containers
- 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
-
Start Simple: Don't over-engineer. Begin with the simplest architecture that could work, then evolve based on actual needs.
-
Document Decisions: Use Architecture Decision Records to capture the context and rationale behind significant choices.
-
Design for Change: Requirements evolve. Build flexibility into your architecture where it matters most.
-
Embrace Trade-offs: Every architectural decision involves trade-offs. Make them explicit and choose based on your specific context.
-
Measure and Monitor: Build observability into your architecture from the start. You can't improve what you can't measure.
-
Automate Quality: Use architecture tests, linting, and CI/CD to enforce architectural rules automatically.
-
Think About Failure: Design for resilience. Consider what happens when components fail and build in appropriate safeguards.
-
Optimize for Team: Consider your team's size, skills, and organization when choosing architectural patterns.
-
Prioritize Security: Build security into every layer rather than adding it as an afterthought.
-
Keep Learning: Software architecture is constantly evolving. Stay current with patterns, practices, and technologies.
Related Topics
- Architecture Decision Records - Document your architectural decisions
- Design Patterns - Common solutions to recurring problems
- Technical Debt - Managing architectural compromises
- Documentation - Communicating architecture effectively
- Logging - Building observable systems
- Testing - Validating architectural decisions