Clean Architecture in Real-World Projects: Lessons Learned

December 15, 2024
Software Architecture
10 min read
Clean Architecture in Real-World Projects: Lessons Learned

Clean Architecture promises maintainable, testable, and flexible code. But how does it hold up in real-world projects? After implementing Clean Architecture in MediSense and several client projects, I've learned valuable lessons about when it shines, where it struggles, and how to adapt it for practical success.

Understanding Clean Architecture

Clean Architecture, popularized by Robert C. Martin (Uncle Bob), organizes code into concentric circles where dependencies point inward. The core principle is that business logic should be independent of external concerns like databases, frameworks, and UI.

The Four Layers

  • Entities: Core business objects and rules
  • Use Cases: Application-specific business rules
  • Interface Adapters: Controllers, presenters, gateways
  • Frameworks & Drivers: External tools, databases, web frameworks

"The architecture should scream about the use cases of the application, not about the frameworks it uses."

MediSense: A Clean Architecture Case Study

When building MediSense, I applied Clean Architecture principles to create a maintainable AI-powered healthcare platform. Here's how the layers mapped to real components:

Entities Layer


// Core business entities
class Patient {
  constructor(id, name, age, medicalHistory) {
    this.id = id;
    this.name = name;
    this.age = age;
    this.medicalHistory = medicalHistory;
  }

  addSymptom(symptom) {
    // Business rule: validate symptom before adding
    if (!symptom || !symptom.name) {
      throw new Error('Invalid symptom');
    }
    this.symptoms.push(symptom);
  }

  isEligibleForDiagnosis() {
    // Business rule: need at least 2 symptoms for diagnosis
    return this.symptoms.length >= 2;
  }
}

class Diagnosis {
  constructor(patientId, disease, confidence, recommendations) {
    this.patientId = patientId;
    this.disease = disease;
    this.confidence = confidence;
    this.recommendations = recommendations;
    this.timestamp = new Date();
  }

  isHighConfidence() {
    return this.confidence > 0.8;
  }
}
            

Use Cases Layer


// Application-specific business logic
class DiagnosePatientsUseCase {
  constructor(patientRepository, mlModelService, auditLogger) {
    this.patientRepository = patientRepository;
    this.mlModelService = mlModelService;
    this.auditLogger = auditLogger;
  }

  async execute(patientId, symptoms) {
    // Validate input
    if (!patientId || !symptoms || symptoms.length === 0) {
      throw new Error('Invalid input for diagnosis');
    }

    // Get patient
    const patient = await this.patientRepository.findById(patientId);
    if (!patient) {
      throw new Error('Patient not found');
    }

    // Add symptoms to patient
    symptoms.forEach(symptom => patient.addSymptom(symptom));

    // Check business rules
    if (!patient.isEligibleForDiagnosis()) {
      throw new Error('Insufficient symptoms for diagnosis');
    }

    // Perform ML prediction
    const prediction = await this.mlModelService.predict(patient.symptoms);

    // Create diagnosis
    const diagnosis = new Diagnosis(
      patientId,
      prediction.disease,
      prediction.confidence,
      prediction.recommendations
    );

    // Audit logging
    await this.auditLogger.log('diagnosis_created', {
      patientId,
      disease: diagnosis.disease,
      confidence: diagnosis.confidence
    });

    return diagnosis;
  }
}
            

Interface Adapters Layer


// Controllers handle HTTP requests
class DiagnosisController {
  constructor(diagnosePatientsUseCase) {
    this.diagnosePatientsUseCase = diagnosePatientsUseCase;
  }

  async diagnose(req, res) {
    try {
      const { patientId, symptoms } = req.body;
      
      const diagnosis = await this.diagnosePatientsUseCase.execute(
        patientId, 
        symptoms
      );

      res.json({
        success: true,
        data: {
          disease: diagnosis.disease,
          confidence: diagnosis.confidence,
          recommendations: diagnosis.recommendations,
          timestamp: diagnosis.timestamp
        }
      });
    } catch (error) {
      res.status(400).json({
        success: false,
        error: error.message
      });
    }
  }
}

// Repository implementations
class MongoPatientRepository {
  constructor(mongoClient) {
    this.db = mongoClient.db('medisense');
    this.collection = this.db.collection('patients');
  }

  async findById(id) {
    const doc = await this.collection.findOne({ _id: id });
    if (!doc) return null;

    return new Patient(doc._id, doc.name, doc.age, doc.medicalHistory);
  }

  async save(patient) {
    await this.collection.updateOne(
      { _id: patient.id },
      { $set: patient },
      { upsert: true }
    );
  }
}
            

Real-World Benefits

1. Testability

Clean Architecture made testing MediSense straightforward. Business logic could be tested independently of external dependencies:


// Testing use cases without external dependencies
describe('DiagnosePatientsUseCase', () => {
  let useCase;
  let mockPatientRepo;
  let mockMLService;
  let mockAuditLogger;

  beforeEach(() => {
    mockPatientRepo = {
      findById: jest.fn(),
      save: jest.fn()
    };
    mockMLService = {
      predict: jest.fn()
    };
    mockAuditLogger = {
      log: jest.fn()
    };

    useCase = new DiagnosePatientsUseCase(
      mockPatientRepo,
      mockMLService,
      mockAuditLogger
    );
  });

  test('should diagnose patient with valid symptoms', async () => {
    // Arrange
    const patient = new Patient('123', 'John Doe', 30, []);
    mockPatientRepo.findById.mockResolvedValue(patient);
    mockMLService.predict.mockResolvedValue({
      disease: 'Common Cold',
      confidence: 0.85,
      recommendations: ['Rest', 'Hydration']
    });

    // Act
    const diagnosis = await useCase.execute('123', [
      { name: 'fever' },
      { name: 'cough' }
    ]);

    // Assert
    expect(diagnosis.disease).toBe('Common Cold');
    expect(diagnosis.confidence).toBe(0.85);
    expect(mockAuditLogger.log).toHaveBeenCalled();
  });
});
            

2. Flexibility

When requirements changed, Clean Architecture made adaptations easier. For example, switching from MongoDB to PostgreSQL only required changing the repository implementation:


// New PostgreSQL implementation
class PostgresPatientRepository {
  constructor(pgClient) {
    this.client = pgClient;
  }

  async findById(id) {
    const result = await this.client.query(
      'SELECT * FROM patients WHERE id = $1',
      [id]
    );
    
    if (result.rows.length === 0) return null;

    const row = result.rows[0];
    return new Patient(row.id, row.name, row.age, row.medical_history);
  }

  async save(patient) {
    await this.client.query(
      `INSERT INTO patients (id, name, age, medical_history) 
       VALUES ($1, $2, $3, $4) 
       ON CONFLICT (id) DO UPDATE SET 
       name = $2, age = $3, medical_history = $4`,
      [patient.id, patient.name, patient.age, patient.medicalHistory]
    );
  }
}
            

3. Maintainability

Clear separation of concerns made the codebase easier to understand and modify. New developers could quickly grasp the system structure and locate relevant code.

Real-World Challenges

1. Over-Engineering for Simple Projects

Not every project needs Clean Architecture. For a simple CRUD API with basic business logic, the overhead might not be justified.

When to Use Clean Architecture:

  • Complex business logic
  • Multiple external integrations
  • Long-term maintenance requirements
  • Team collaboration needs
  • Frequent requirement changes

When to Consider Simpler Approaches:

  • Simple CRUD operations
  • Prototype or proof-of-concept projects
  • Small team with tight deadlines
  • Well-defined, stable requirements

2. Learning Curve

Clean Architecture requires a mindset shift. Team members need time to understand dependency inversion and the layered approach.

Strategies for Team Adoption:

  • Start with training sessions on Clean Architecture principles
  • Create templates and examples for common patterns
  • Implement gradually, one module at a time
  • Establish code review practices focused on architecture

3. Performance Considerations

The abstraction layers can introduce performance overhead. In MediSense, I had to optimize critical paths:


// Performance optimization: caching in repository layer
class CachedPatientRepository {
  constructor(baseRepository, cache) {
    this.baseRepository = baseRepository;
    this.cache = cache;
  }

  async findById(id) {
    const cacheKey = `patient:${id}`;
    let patient = await this.cache.get(cacheKey);

    if (!patient) {
      patient = await this.baseRepository.findById(id);
      if (patient) {
        await this.cache.set(cacheKey, patient, 300); // 5 min TTL
      }
    }

    return patient;
  }
}
            

Practical Implementation Guidelines

Start with the Core

Begin by identifying and implementing your core business entities and use cases. These should be framework-agnostic and contain your essential business logic.

Use Dependency Injection

Implement a dependency injection container to manage dependencies between layers:


// Simple DI container example
class DIContainer {
  constructor() {
    this.services = new Map();
  }

  register(name, factory) {
    this.services.set(name, factory);
  }

  resolve(name) {
    const factory = this.services.get(name);
    if (!factory) {
      throw new Error(`Service ${name} not found`);
    }
    return factory();
  }
}

// Registration
container.register('patientRepository', () => 
  new MongoPatientRepository(mongoClient)
);

container.register('mlModelService', () => 
  new TensorFlowMLService()
);

container.register('diagnosePatientsUseCase', () => 
  new DiagnosePatientsUseCase(
    container.resolve('patientRepository'),
    container.resolve('mlModelService'),
    container.resolve('auditLogger')
  )
);
            

Define Clear Interfaces

Use interfaces or abstract classes to define contracts between layers:


// Interface definitions
interface PatientRepository {
  findById(id: string): Promise;
  save(patient: Patient): Promise;
  findBySymptoms(symptoms: string[]): Promise;
}

interface MLModelService {
  predict(symptoms: Symptom[]): Promise;
  retrain(data: TrainingData): Promise;
}

interface AuditLogger {
  log(event: string, data: object): Promise;
}
            

Lessons Learned

1. Pragmatic Application

Don't follow Clean Architecture dogmatically. Adapt it to your project's needs. Sometimes a direct database call in a controller is acceptable for simple operations.

2. Gradual Implementation

You don't need to implement Clean Architecture perfectly from day one. Start with the most critical parts and refactor gradually.

3. Team Alignment

Ensure the entire team understands and buys into the architectural approach. Inconsistent application can lead to a messy codebase.

4. Documentation is Crucial

Document your architectural decisions, especially the reasoning behind layer boundaries and dependency flows.

5. Tooling Matters

Invest in tools that support your architecture:

  • Dependency injection frameworks
  • Testing frameworks that support mocking
  • Code analysis tools for architecture validation
  • Documentation generators

When Clean Architecture Shines

Complex Business Logic

MediSense had complex diagnostic algorithms, regulatory compliance requirements, and multiple integration points. Clean Architecture provided the structure needed to manage this complexity.

Evolving Requirements

Healthcare regulations change frequently. Clean Architecture made it easier to adapt to new compliance requirements without major rewrites.

Multiple Interfaces

MediSense needed to support web APIs, mobile apps, and integration with hospital systems. The clean separation made it easy to add new interfaces.

Conclusion

Clean Architecture is a powerful tool, but it's not a silver bullet. It excels in complex, long-lived projects with evolving requirements. However, it can be overkill for simple applications.

The key is to understand the principles behind Clean Architecture and apply them judiciously. Focus on separation of concerns, dependency inversion, and testability, but don't sacrifice pragmatism for architectural purity.

In MediSense, Clean Architecture enabled us to build a maintainable, testable system that could adapt to changing healthcare requirements. The initial investment in architectural setup paid dividends in reduced maintenance costs and faster feature development.

Remember: architecture should serve your project's goals, not the other way around. Use Clean Architecture when it adds value, and don't be afraid to adapt it to your specific needs.

Tags

Clean Architecture Software Design Best Practices Maintainability Testing Dependency Injection