Best Clean Architecture Examples in JavaScript
Best Clean Architecture Examples in JavaScript
Clean architecture implementations in JavaScript fail when developers focus on folder structure while ignoring dependency rules. You end up with perfectly organized directories—entities, use-cases, controllers—but the entities import axios, the use-cases import express, and the domain logic is still coupled to npm packages that change with every major version release. The architecture diagram looks textbook but npm audit shows your business logic depends on 47 transitive dependencies.
This article shows clean architecture implementations that actually work in JavaScript and TypeScript projects. You'll see how to structure Node.js applications so business logic has zero framework dependencies, how to implement use cases that can be tested without spinning up servers or databases, how to use dependency injection in JavaScript without heavyweight DI containers, and how to organize code so the most important parts (domain logic) are the most stable and the least important parts (UI, database, frameworks) are the most volatile. These examples come from production codebases handling millions of requests daily.
We'll cover complete examples for REST APIs, React applications, and serverless functions, showing how clean architecture principles adapt to different JavaScript contexts while maintaining the core benefits: testability, maintainability, and independence from external dependencies.
Why JavaScript Needs Clean Architecture
JavaScript's ecosystem moves faster than most languages. Frameworks rise and fall in years, not decades. Build tools, state management libraries, ORM packages—all churn at a pace that makes long-term maintenance challenging. When business logic is coupled to these volatile dependencies, you face constant refactoring just to keep applications running on current versions.
Clean architecture addresses this by isolating business logic from framework and library code. Your domain entities, business rules, and use cases don't import React, Express, or TypeORM. They use plain JavaScript objects and functions. When you need to migrate from Express to Fastify, or React to Solid, or TypeORM to Prisma, the business logic remains untouched because it never depended on those frameworks.
The second benefit is testability. JavaScript testing becomes slow when tests require framework initialization. Testing a controller that imports Express means loading Express. Testing a component that imports React means setting up React testing utilities. Clean architecture lets you test business logic with pure unit tests—instantiate plain objects, call methods, assert results. No framework, no setup, tests run in milliseconds.
Example 1: Express REST API with Clean Architecture
A REST API built with clean architecture separates HTTP handling (Express controllers) from business logic (use cases) from data access (repositories). The Express layer adapts HTTP requests to use case calls. Use cases orchestrate domain logic. Repositories abstract data persistence. Each layer depends only on inner layers, never outer ones.
Project Structure
src/
├── domain/ # Entities and business rules (no dependencies)
│ ├── entities/
│ │ ├── User.js
│ │ ├── Order.js
│ │ └── Product.js
│ └── errors/
│ └── DomainError.js
│
├── use-cases/ # Application logic (depends on domain)
│ ├── user/
│ │ ├── CreateUser.js
│ │ ├── GetUser.js
│ │ └── UpdateUser.js
│ └── order/
│ ├── PlaceOrder.js
│ └── CancelOrder.js
│
├── interface-adapters/ # Adapters for external interfaces
│ ├── controllers/ # HTTP controllers
│ │ ├── UserController.js
│ │ └── OrderController.js
│ ├── presenters/ # Response formatting
│ │ └── UserPresenter.js
│ └── repositories/ # Data access interfaces
│ ├── UserRepository.js
│ └── OrderRepository.js
│
├── infrastructure/ # External dependencies
│ ├── database/
│ │ ├── PostgresUserRepository.js
│ │ └── PostgresOrderRepository.js
│ ├── web/
│ │ ├── express-app.js
│ │ └── routes.js
│ └── config/
│ └── database.js
│
└── main.js # Application entry point (wiring)
Domain Layer: Entities
// src/domain/entities/User.js
// Pure JavaScript - no external dependencies
class User {
constructor({ id, email, passwordHash, createdAt, isActive = true }) {
this.id = id;
this.email = email;
this.passwordHash = passwordHash;
this.createdAt = createdAt || new Date();
this.isActive = isActive;
this.validate();
}
validate() {
if (!this.email || !this.email.includes('@')) {
throw new Error('Invalid email address');
}
if (!this.passwordHash) {
throw new Error('Password hash is required');
}
}
deactivate() {
this.isActive = false;
}
activate() {
this.isActive = true;
}
updateEmail(newEmail) {
if (!newEmail || !newEmail.includes('@')) {
throw new Error('Invalid email address');
}
this.email = newEmail;
}
}
module.exports = User;
The User entity is plain JavaScript. No imports, no framework dependencies. It encapsulates business rules (email validation, active status) and can be tested with zero setup.
Use Case Layer
// src/use-cases/user/CreateUser.js
const User = require('../../domain/entities/User');
class CreateUser {
constructor({ userRepository, passwordHasher, emailValidator }) {
this.userRepository = userRepository;
this.passwordHasher = passwordHasher;
this.emailValidator = emailValidator;
}
async execute({ email, password }) {
// Validate input
if (!this.emailValidator.isValid(email)) {
throw new Error('Invalid email format');
}
// Check if user already exists
const existingUser = await this.userRepository.findByEmail(email);
if (existingUser) {
throw new Error('User with this email already exists');
}
// Hash password
const passwordHash = await this.passwordHasher.hash(password);
// Create domain entity
const user = new User({
id: this.generateId(),
email,
passwordHash
});
// Persist
await this.userRepository.save(user);
return {
id: user.id,
email: user.email,
createdAt: user.createdAt
};
}
generateId() {
return `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
}
module.exports = CreateUser;
The use case depends on abstractions (userRepository, passwordHasher) not implementations. It doesn't know about Postgres or bcrypt—those are injected dependencies. This makes the use case testable with fakes.
Interface Adapters: Controller
// src/interface-adapters/controllers/UserController.js
class UserController {
constructor({ createUser, getUser, updateUser }) {
this.createUser = createUser;
this.getUser = getUser;
this.updateUser = updateUser;
}
async handleCreateUser(req, res) {
try {
const { email, password } = req.body;
const result = await this.createUser.execute({ email, password });
res.status(201).json({
success: true,
data: result
});
} catch (error) {
if (error.message.includes('already exists')) {
return res.status(409).json({
success: false,
error: error.message
});
}
res.status(400).json({
success: false,
error: error.message
});
}
}
async handleGetUser(req, res) {
try {
const { id } = req.params;
const user = await this.getUser.execute({ id });
if (!user) {
return res.status(404).json({
success: false,
error: 'User not found'
});
}
res.json({
success: true,
data: user
});
} catch (error) {
res.status(500).json({
success: false,
error: 'Internal server error'
});
}
}
}
module.exports = UserController;
The controller handles HTTP concerns (status codes, request parsing, response formatting) and delegates business logic to use cases. It depends on use case interfaces, not implementations.
Infrastructure: Repository Implementation
// src/infrastructure/database/PostgresUserRepository.js
const User = require('../../domain/entities/User');
class PostgresUserRepository {
constructor(pool) {
this.pool = pool;
}
async save(user) {
await this.pool.query(`
INSERT INTO users (id, email, password_hash, created_at, is_active)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (id) DO UPDATE SET
email = EXCLUDED.email,
password_hash = EXCLUDED.password_hash,
is_active = EXCLUDED.is_active
`, [user.id, user.email, user.passwordHash, user.createdAt, user.isActive]);
}
async findById(id) {
const result = await this.pool.query(
'SELECT * FROM users WHERE id = $1',
[id]
);
if (result.rows.length === 0) return null;
return this.toDomain(result.rows[0]);
}
async findByEmail(email) {
const result = await this.pool.query(
'SELECT * FROM users WHERE email = $1',
[email]
);
if (result.rows.length === 0) return null;
return this.toDomain(result.rows[0]);
}
toDomain(row) {
return new User({
id: row.id,
email: row.email,
passwordHash: row.password_hash,
createdAt: row.created_at,
isActive: row.is_active
});
}
}
module.exports = PostgresUserRepository;
Wiring: Dependency Injection
// src/main.js
const express = require('express');
const { Pool } = require('pg');
const bcrypt = require('bcrypt');
// Use cases
const CreateUser = require('./use-cases/user/CreateUser');
const GetUser = require('./use-cases/user/GetUser');
// Repositories
const PostgresUserRepository = require('./infrastructure/database/PostgresUserRepository');
// Controllers
const UserController = require('./interface-adapters/controllers/UserController');
// Setup infrastructure
const pool = new Pool({
host: process.env.DB_HOST,
database: process.env.DB_NAME,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD
});
// Create repository instances
const userRepository = new PostgresUserRepository(pool);
// Create service implementations
const passwordHasher = {
hash: (password) => bcrypt.hash(password, 10),
compare: (password, hash) => bcrypt.compare(password, hash)
};
const emailValidator = {
isValid: (email) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
};
// Create use case instances
const createUser = new CreateUser({
userRepository,
passwordHasher,
emailValidator
});
const getUser = new GetUser({ userRepository });
// Create controller instances
const userController = new UserController({
createUser,
getUser
});
// Setup Express
const app = express();
app.use(express.json());
// Routes
app.post('/users', (req, res) => userController.handleCreateUser(req, res));
app.get('/users/:id', (req, res) => userController.handleGetUser(req, res));
// Start server
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
All wiring happens in main.js. Infrastructure components (Postgres, bcrypt) are instantiated here and injected into use cases. This is the only file that knows about specific implementations.
Example 2: React Application with Clean Architecture
Clean architecture in React applications separates UI components from business logic and state management. Components handle rendering and user interaction. Use cases handle business logic. Repositories abstract data fetching (API calls). This separation makes business logic reusable across different UI frameworks.
Project Structure
src/
├── domain/
│ ├── entities/
│ │ ├── Product.js
│ │ └── ShoppingCart.js
│ └── errors/
│ └── CartError.js
│
├── use-cases/
│ ├── AddProductToCart.js
│ ├── RemoveProductFromCart.js
│ └── CheckoutCart.js
│
├── interface-adapters/
│ ├── repositories/
│ │ ├── ProductRepository.js
│ │ └── CartRepository.js
│ ├── presenters/
│ │ └── CartPresenter.js
│ └── state/ # State management
│ └── CartStore.js
│
├── infrastructure/
│ ├── api/
│ │ ├── ApiProductRepository.js
│ │ └── ApiCartRepository.js
│ └── storage/
│ └── LocalStorageCartRepository.js
│
└── ui/ # React components
├── components/
│ ├── ProductList.jsx
│ ├── CartView.jsx
│ └── CheckoutButton.jsx
└── pages/
└── ShoppingPage.jsx
Domain Entity
// src/domain/entities/ShoppingCart.js
class ShoppingCart {
constructor(items = []) {
this.items = items;
}
addItem(product, quantity = 1) {
const existingItem = this.items.find(
item => item.product.id === product.id
);
if (existingItem) {
existingItem.quantity += quantity;
} else {
this.items.push({ product, quantity });
}
}
removeItem(productId) {
this.items = this.items.filter(
item => item.product.id !== productId
);
}
updateQuantity(productId, quantity) {
const item = this.items.find(item => item.product.id === productId);
if (item) {
item.quantity = quantity;
}
}
getTotal() {
return this.items.reduce(
(sum, item) => sum + (item.product.price * item.quantity),
0
);
}
getItemCount() {
return this.items.reduce((sum, item) => sum + item.quantity, 0);
}
clear() {
this.items = [];
}
isEmpty() {
return this.items.length === 0;
}
}
export default ShoppingCart;
Use Case
// src/use-cases/AddProductToCart.js
import ShoppingCart from '../domain/entities/ShoppingCart';
class AddProductToCart {
constructor({ cartRepository, productRepository }) {
this.cartRepository = cartRepository;
this.productRepository = productRepository;
}
async execute({ productId, quantity = 1 }) {
// Load current cart
const cart = await this.cartRepository.getCart();
// Load product details
const product = await this.productRepository.findById(productId);
if (!product) {
throw new Error('Product not found');
}
if (product.stock < quantity) {
throw new Error('Insufficient stock');
}
// Add to cart (domain logic)
cart.addItem(product, quantity);
// Persist updated cart
await this.cartRepository.saveCart(cart);
return {
cart,
itemCount: cart.getItemCount(),
total: cart.getTotal()
};
}
}
export default AddProductToCart;
React Component
// src/ui/components/ProductList.jsx
import React from 'react';
import { useCart } from '../hooks/useCart';
function ProductList({ products }) {
const { addProduct, isLoading } = useCart();
const handleAddToCart = async (product) => {
try {
await addProduct(product.id, 1);
// Show success notification
} catch (error) {
// Show error notification
console.error(error);
}
};
return (
{products.map(product => (
{product.name}
${product.price}
Stock: {product.stock}
))}
);
}
export default ProductList;
Custom Hook (Adapter)
// src/ui/hooks/useCart.js
import { useState, useCallback } from 'react';
import AddProductToCart from '../../use-cases/AddProductToCart';
import { cartRepository, productRepository } from '../../infrastructure/repositories';
export function useCart() {
const [cart, setCart] = useState(null);
const [isLoading, setIsLoading] = useState(false);
const addProduct = useCallback(async (productId, quantity) => {
setIsLoading(true);
try {
const addToCart = new AddProductToCart({
cartRepository,
productRepository
});
const result = await addToCart.execute({ productId, quantity });
setCart(result.cart);
return result;
} finally {
setIsLoading(false);
}
}, []);
return {
cart,
addProduct,
isLoading
};
}
export default useCart;
The React component depends on a custom hook, which depends on use cases. Business logic (stock validation, cart updates) lives in use cases and entities, not in React components. This makes the logic reusable—you could build a Vue or Svelte version that uses the same use cases.
Example 3: AWS Lambda with Clean Architecture
Serverless functions benefit from clean architecture because it separates business logic from the Lambda runtime. Your domain code doesn't know about Lambda events or context objects. This makes testing easier and enables running the same business logic in different environments (Lambda, Express, CLI).
Project Structure
src/
├── domain/
│ └── entities/
│ └── Order.js
│
├── use-cases/
│ └── ProcessOrder.js
│
├── interface-adapters/
│ └── lambda/
│ └── OrderHandler.js
│
└── infrastructure/
├── repositories/
│ └── DynamoDBOrderRepository.js
└── services/
└── SQSNotificationService.js
Lambda Handler (Adapter)
// src/interface-adapters/lambda/OrderHandler.js
const ProcessOrder = require('../../use-cases/ProcessOrder');
const DynamoDBOrderRepository = require('../../infrastructure/repositories/DynamoDBOrderRepository');
const SQSNotificationService = require('../../infrastructure/services/SQSNotificationService');
// Initialize dependencies once (outside handler for reuse across invocations)
const orderRepository = new DynamoDBOrderRepository(
process.env.ORDERS_TABLE
);
const notificationService = new SQSNotificationService(
process.env.NOTIFICATION_QUEUE_URL
);
const processOrder = new ProcessOrder({
orderRepository,
notificationService
});
exports.handler = async (event) => {
try {
// Parse Lambda event
const body = JSON.parse(event.body);
// Execute use case
const result = await processOrder.execute({
customerId: body.customerId,
items: body.items,
paymentMethod: body.paymentMethod
});
// Format Lambda response
return {
statusCode: 200,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
success: true,
orderId: result.orderId,
total: result.total,
status: result.status
})
};
} catch (error) {
console.error('Order processing failed:', error);
return {
statusCode: error.message.includes('not found') ? 404 : 400,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
success: false,
error: error.message
})
};
}
};
The Lambda handler is a thin adapter. It parses the Lambda event, calls the use case, and formats the response. All business logic lives in ProcessOrder, which has no Lambda dependencies.
Testing Lambda Without Lambda
// tests/use-cases/ProcessOrder.test.js
const ProcessOrder = require('../../src/use-cases/ProcessOrder');
describe('ProcessOrder', () => {
it('should create order with valid items', async () => {
// Fake repository (in-memory)
const fakeOrderRepo = {
orders: [],
save: async function(order) {
this.orders.push(order);
},
findById: async function(id) {
return this.orders.find(o => o.id === id);
}
};
// Fake notification service
const fakeNotifications = {
notifications: [],
send: async function(notification) {
this.notifications.push(notification);
}
};
const processOrder = new ProcessOrder({
orderRepository: fakeOrderRepo,
notificationService: fakeNotifications
});
const result = await processOrder.execute({
customerId: 'customer-123',
items: [
{ productId: 'product-1', quantity: 2, price: 29.99 }
],
paymentMethod: 'credit-card'
});
expect(result.orderId).toBeDefined();
expect(result.total).toBe(59.98);
expect(fakeOrderRepo.orders.length).toBe(1);
expect(fakeNotifications.notifications.length).toBe(1);
});
});
Testing requires no Lambda runtime, no DynamoDB, no SQS. Pure unit tests with fakes run instantly and test business logic in isolation.
Practical Patterns for JavaScript Clean Architecture
Dependency Injection Without Containers
JavaScript's dynamic nature makes manual dependency injection straightforward. Use factory functions or constructor injection to provide dependencies.
// Factory pattern for creating use cases
function createUserUseCases(dependencies) {
const { userRepository, passwordHasher, emailValidator } = dependencies;
return {
createUser: new CreateUser({ userRepository, passwordHasher, emailValidator }),
getUser: new GetUser({ userRepository }),
updateUser: new UpdateUser({ userRepository, passwordHasher }),
deleteUser: new DeleteUser({ userRepository })
};
}
// Usage in main.js
const userUseCases = createUserUseCases({
userRepository: new PostgresUserRepository(pool),
passwordHasher: bcryptHasher,
emailValidator: simpleEmailValidator
});
Handling Async Operations
JavaScript's async/await syntax works naturally with clean architecture. Use cases return promises, repositories are async, controllers await use case results.
// Use case with async operations
class PlaceOrder {
async execute({ customerId, items }) {
// All operations are async
const customer = await this.customerRepository.findById(customerId);
const products = await this.productRepository.findByIds(
items.map(i => i.productId)
);
// Domain logic (synchronous)
const order = customer.createOrder(products, items);
// Async persistence
await this.orderRepository.save(order);
await this.eventPublisher.publish(new OrderPlaced(order));
return order;
}
}
Error Handling Across Layers
Domain errors (business rule violations) should be distinct from infrastructure errors (network failures, database errors). Create domain-specific error classes.
// Domain errors
class DomainError extends Error {
constructor(message) {
super(message);
this.name = this.constructor.name;
}
}
class InsufficientStockError extends DomainError {}
class InvalidEmailError extends DomainError {}
class OrderNotFoundError extends DomainError {}
// Controller handles different error types
async handlePlaceOrder(req, res) {
try {
const result = await this.placeOrder.execute(req.body);
res.status(201).json({ success: true, data: result });
} catch (error) {
if (error instanceof InsufficientStockError) {
return res.status(400).json({ error: error.message });
}
if (error instanceof OrderNotFoundError) {
return res.status(404).json({ error: error.message });
}
// Infrastructure errors
console.error('Unexpected error:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
Testing Strategies
Unit Tests: Domain and Use Cases
Test domain entities and use cases with no infrastructure dependencies. Use fakes (in-memory implementations) for repositories and services.
// Test domain entity
describe('ShoppingCart', () => {
it('should calculate total correctly', () => {
const cart = new ShoppingCart();
cart.addItem({ id: '1', price: 10 }, 2);
cart.addItem({ id: '2', price: 5 }, 3);
expect(cart.getTotal()).toBe(35);
});
it('should throw error when removing non-existent item', () => {
const cart = new ShoppingCart();
expect(() => cart.removeItem('invalid')).toThrow();
});
});
// Test use case with fakes
describe('CreateUser', () => {
it('should create user with valid data', async () => {
const fakeRepo = {
users: [],
findByEmail: async (email) =>
fakeRepo.users.find(u => u.email === email),
save: async (user) => fakeRepo.users.push(user)
};
const createUser = new CreateUser({
userRepository: fakeRepo,
passwordHasher: { hash: async (pwd) => `hashed_${pwd}` },
emailValidator: { isValid: (email) => email.includes('@') }
});
const result = await createUser.execute({
email: '[email protected]',
password: 'password123'
});
expect(result.email).toBe('[email protected]');
expect(fakeRepo.users.length).toBe(1);
});
});
Integration Tests: Adapters and Infrastructure
// Test repository with real database
describe('PostgresUserRepository', () => {
let pool, repository;
beforeEach(async () => {
pool = new Pool({ /* test db config */ });
repository = new PostgresUserRepository(pool);
await pool.query('TRUNCATE users CASCADE');
});
afterEach(async () => {
await pool.end();
});
it('should save and retrieve user', async () => {
const user = new User({
id: 'user-123',
email: '[email protected]',
passwordHash: 'hashed'
});
await repository.save(user);
const retrieved = await repository.findById('user-123');
expect(retrieved.email).toBe('[email protected]');
});
});
| Test Type | What to Test | Dependencies | Speed |
|---|---|---|---|
| Unit | Domain entities, use cases | None (use fakes) | Very fast (ms) |
| Integration | Repositories, adapters | Real database/services | Slower (seconds) |
| E2E | Full API/app flows | All infrastructure | Slowest (seconds to minutes) |
Common Pitfalls and Solutions
Over-Abstracting Simple Operations
Not every database query needs a repository. Not every function needs a use case. If you're just fetching a list for display with no business logic, a simple query function is fine. Apply clean architecture where business logic exists, not everywhere.
Mixing Framework Code Into Domain
It's tempting to import utility libraries (lodash, date-fns) in domain code. This creates dependencies that aren't critical. For truly shared utilities, create thin wrappers or use only standard library features in domain code.
// Avoid: Domain importing external library
import _ from 'lodash';
class Order {
getUniqueProductIds() {
return _.uniq(this.items.map(i => i.productId));
}
}
// Better: Use standard library or create utility
class Order {
getUniqueProductIds() {
return [...new Set(this.items.map(i => i.productId))];
}
}
Testing Through Frameworks Instead of Directly
Don't test business logic by making HTTP requests to Express or rendering React components. Test the use cases directly. Test controllers/components separately to verify they call use cases correctly.
Frequently Asked Questions
Do I need TypeScript for clean architecture in JavaScript?
No, but TypeScript helps. Interfaces in TypeScript make dependency contracts explicit and the compiler enforces them. In plain JavaScript, you rely on duck typing and runtime checks. Both work, but TypeScript catches dependency violations at build time rather than runtime. For larger codebases or teams, TypeScript's compile-time checks provide significant value.
How do I handle environment configuration in clean architecture?
Configuration is an infrastructure concern. Load it at the composition root (main.js) and inject it where needed. Domain and use cases should receive configuration values as constructor parameters, not read from process.env directly. This makes them testable with different configuration values.
Should I use classes or functions for use cases?
Both work. Classes with constructor injection make dependencies explicit and are easier to compose. Pure functions work well for simple use cases but dependency injection becomes manual parameter passing. Choose based on team preference and complexity. Classes provide more structure; functions are lighter weight.
How do I handle database transactions across multiple repositories?
Pass a transaction context or unit of work to repositories. The use case starts a transaction, passes it to repository methods, and commits or rolls back based on success. This keeps transaction management in use cases while allowing repositories to participate in the transaction.
Can I use clean architecture with existing frameworks like NestJS or Next.js?
Yes. Treat the framework as the outer layer (infrastructure). Your domain and use cases remain framework-independent. Framework-specific code (NestJS controllers, Next.js API routes) acts as adapters that call use cases. This requires discipline to prevent framework features from leaking into business logic, but it's achievable.
How do I organize GraphQL resolvers in clean architecture?
Resolvers are adapters. They receive GraphQL queries/mutations, map them to use case calls, and format results as GraphQL responses. Keep resolvers thin—just translation between GraphQL and domain. Business logic stays in use cases.
Should repositories return domain entities or DTOs?
Repositories should return domain entities. They're part of the domain boundary, translating between database representation and domain model. DTOs are used at the UI boundary (controllers, presenters) to format data for external consumption. This separation keeps domain entities focused on business logic, not serialization.
How do I handle file uploads in clean architecture?
File uploads are infrastructure. Create a FileStorage port that defines operations (save, retrieve, delete). The use case depends on this port. Infrastructure provides implementations (S3Storage, LocalDiskStorage). The controller handles multipart form parsing and passes file buffers to the use case, which uses the FileStorage port to persist them.
Can I use ORMs like TypeORM or Prisma with clean architecture?
Yes, but keep ORM code in repositories (infrastructure layer). Don't let ORM entities become your domain entities—they're coupled to database schema and ORM features. Create separate domain entities with business logic. Repositories map between ORM entities and domain entities. This requires more code but maintains independence from the ORM.
How do I migrate an existing JavaScript project to clean architecture?
Start with one feature or module. Extract domain entities and business logic. Create use cases that orchestrate them. Build adapters for existing infrastructure (database, APIs). Gradually expand to other parts of the codebase. You don't need to rewrite everything at once—clean architecture can be adopted incrementally.
Conclusion
Clean architecture in JavaScript projects delivers on its promise when it separates business logic from framework and infrastructure dependencies. Domain entities and use cases written in plain JavaScript (or TypeScript) can run anywhere—Node.js, browsers, serverless functions—without modification. Testing becomes faster because business logic has no infrastructure dependencies. Maintenance becomes easier because framework upgrades don't require rewriting business rules.
The key is disciplined dependency management: inner layers (domain, use cases) depend on nothing but their own interfaces. Outer layers (controllers, repositories) depend on inner layers and implement the interfaces they define. This inversion of dependencies creates the flexibility and testability that make clean architecture valuable.
Start by identifying your core business logic and extracting it into entities and use cases with no external dependencies. Define port interfaces for infrastructure needs. Implement adapters that provide those dependencies using actual infrastructure. Wire everything together at the application entry point. The result is a codebase where the most important code (business logic) is the most stable, and the least important code (frameworks, databases, UI) is the most changeable.