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.