Layer-Based Architecture for React Apps
Layered architecture is the foundation of maintainable large-scale systems. Instead of scattering business logic through React components, you organize code into four layers: presentation (React UI), application (workflows), domain (entities and rules), and infrastructure (APIs and data access). Each layer has a single responsibility and knows nothing about layers above it. The result: your business logic survives a framework change, tests are fast, and new developers understand what code does what. This article walks you through implementing layered architecture in a real React app.
The Four Layers Explained
Layer 1: Presentation (UI). React components, hooks, pages. This layer renders data and captures user input. It never contains business logic. If a component does validation, parsing, or API calls, that logic belongs in a lower layer.
Layer 2: Application (Workflows). Use cases, services, command handlers. This layer orchestrates the domain layer to accomplish a user goal. For example: createAccount is an application layer use case that validates the account, creates a domain entity, persists it via infrastructure, and returns a result. React components call application layer functions, never domain layer directly.
Layer 3: Domain (Rules). Entities, value objects, aggregates, business rules. This layer is framework-independent: pure classes and functions that encode your business domain. If you replaced React with Vue, this layer stays unchanged. A user entity enforces that email is unique and password is strong.
Layer 4: Infrastructure (External I/O). Database access, API clients, file storage, authentication providers. This layer adapts external systems to your domain. The database has a flat schema; the infrastructure layer wraps it so the domain layer sees rich entities.
Layers import downward only:
Presentation
↓ imports
Application
↓ imports
Domain
↓ imports
Infrastructure
A Concrete Example: User Registration
Here's how layering works in a real feature:
Layer 4: Infrastructure (Adapters)
// infrastructure/repositories/userRepository.js
// Adapts the database to our domain
export class UserRepository {
async create(user) {
const record = {
email: user.email.value,
passwordHash: user.passwordHash,
createdAt: new Date(),
};
const result = await db.query(
'INSERT INTO users (email, password_hash, created_at) VALUES (?, ?, ?)',
[record.email, record.passwordHash, record.createdAt]
);
return { id: result.lastId, ...record };
}
async findByEmail(email) {
const record = await db.query(
'SELECT * FROM users WHERE email = ?',
[email]
);
return record ? { id: record.id, email: record.email } : null;
}
}
Layer 3: Domain (Entities and Rules)
// domain/entities/User.js
// Encodes business rules about users
export class User {
constructor(email, passwordHash) {
this.email = new Email(email); // value object with validation
this.passwordHash = passwordHash;
this.createdAt = new Date();
}
static async create(email, plainPassword, repository) {
// Business rule: email must be unique
const existing = await repository.findByEmail(email);
if (existing) {
throw new Error('Email already registered');
}
// Business rule: password must be strong
const hash = await hashPassword(plainPassword);
const user = new User(email, hash);
return user;
}
isValidPassword(plainPassword) {
// Password validation logic
return plainPassword.length >= 8;
}
}
// domain/valueobjects/Email.js
// Email is immutable and validates itself
export class Email {
constructor(value) {
if (!value.includes('@')) {
throw new Error('Invalid email');
}
this.value = value;
}
}
Layer 2: Application (Use Cases)
// application/usecases/RegisterUser.js
// Orchestrates domain and infrastructure to register a user
import { User } from '../../domain/entities/User';
import { UserRepository } from '../../infrastructure/repositories/userRepository';
export async function registerUser(email, password) {
const repository = new UserRepository();
try {
// Use the domain entity to enforce business rules
const user = await User.create(email, password, repository);
// Persist via infrastructure layer
const saved = await repository.create(user);
return {
success: true,
userId: saved.id,
email: saved.email,
};
} catch (error) {
return {
success: false,
error: error.message,
};
}
}
Layer 1: Presentation (React Components)
// presentation/pages/RegisterPage.jsx
// React component: captures input and calls application layer
import { registerUser } from '../../application/usecases/RegisterUser';
import { Button } from '../components/Button';
export default function RegisterPage() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [status, setStatus] = useState('idle');
const handleSubmit = async (e) => {
e.preventDefault();
setStatus('loading');
// Call application layer; never touch domain or infra directly
const result = await registerUser(email, password);
if (result.success) {
setStatus('success');
navigate('/dashboard');
} else {
setStatus('error');
setError(result.error);
}
};
return (
<form onSubmit={handleSubmit}>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="[email protected]"
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<Button disabled={status === 'loading'}>Register</Button>
{status === 'error' && <p>{error}</p>}
</form>
);
}
Notice: the React component doesn't know about the database, validation rules, or infrastructure. It calls a use case and renders the result. This separation means you can:
- Test
registerUserwithout React. - Change databases without touching React.
- Reuse
registerUserin a CLI, API, or mobile app. - Test domain rules without any UI framework.
Folder Structure for Layered Architecture
src/
presentation/
pages/
RegisterPage.jsx
LoginPage.jsx
DashboardPage.jsx
components/
Button.jsx
Form.jsx
Header.jsx
hooks/
useRegisterForm.js # presentation state, not business logic
application/
usecases/
RegisterUser.js
LoginUser.js
UpdateProfile.js
services/
AuthenticationService.js # orchestrates domain + infra
domain/
entities/
User.js
Account.js
valueobjects/
Email.js
Password.js
repositories/ # interfaces only
IUserRepository.js
IAccountRepository.js
infrastructure/
repositories/ # implementations
UserRepository.js
AccountRepository.js
api/
ApiClient.js
UserApi.js
database/
connection.js
schema.sql
This scales well for teams of 10–50 people. Domain and application layers can be shared across a monorepo, tested thoroughly, and ported to other UIs (Next.js, mobile, CLI).
Testing Across Layers
Layered architecture makes testing simple and fast:
Layer 3 (Domain): Unit tests, no mocks needed.
// domain/entities/__tests__/User.test.js
describe('User', () => {
it('validates email format', () => {
expect(() => new Email('invalid')).toThrow();
});
it('prevents duplicate registration', async () => {
const repo = { findByEmail: () => ({ id: 1 }) };
await expect(User.create('[email protected]', 'password', repo)).rejects.toThrow();
});
});
Layer 2 (Application): Unit tests with mocked infrastructure.
// application/usecases/__tests__/RegisterUser.test.js
describe('RegisterUser', () => {
it('returns success with userId on registration', async () => {
const mockRepo = {
findByEmail: () => null,
create: jest.fn().mockResolvedValue({ id: 123, email: '[email protected]' })
};
// Mock the dependency
jest.mock('../../infrastructure/repositories/userRepository', () => ({
UserRepository: () => mockRepo
}));
const result = await registerUser('[email protected]', 'password123');
expect(result.success).toBe(true);
expect(result.userId).toBe(123);
});
});
Layer 1 (Presentation): Integration tests with mocked use cases.
// presentation/__tests__/RegisterPage.test.jsx
describe('RegisterPage', () => {
it('calls registerUser on form submit', async () => {
const mockRegisterUser = jest.fn().mockResolvedValue({ success: true, userId: 1 });
jest.mock('../../application/usecases/RegisterUser', () => mockRegisterUser);
const { getByRole } = render(<RegisterPage />);
fireEvent.change(getByRole('textbox', { name: /email/i }), { target: { value: '[email protected]' } });
fireEvent.change(getByRole('textbox', { name: /password/i }), { target: { value: 'password123' } });
fireEvent.click(getByRole('button', { name: /register/i }));
await waitFor(() => {
expect(mockRegisterUser).toHaveBeenCalledWith('[email protected]', 'password123');
});
});
});
Tests of domain layer run in milliseconds. Tests of application layer don't touch databases. Tests of presentation layer can mock API calls. Total test suite runs in seconds, not minutes.
Key Takeaways
- Layered architecture separates concerns: presentation (UI), application (workflows), domain (rules), infrastructure (I/O).
- Imports flow downward only: presentation depends on application, which depends on domain, which depends on infrastructure.
- Business logic lives in domain and application layers, not React components.
- Each layer is independently testable: domain tests have no mocks, application tests mock infrastructure, presentation tests mock use cases.
- Layered architecture lets you port your domain logic to other UIs or reuse it in a CLI or mobile app.
Frequently Asked Questions
How does layered architecture relate to feature-based structure?
They're orthogonal. You can use feature-based structure at the presentation layer and layer-based structure across the entire project. A feature folder might have presentation/ and application/ subfolders specific to that feature, while domain/ and infrastructure/ are shared across features.
Should every value object be a class?
No. Simple immutable objects are fine for small domains. Classes become useful when value objects have behavior: Email validates itself, Password hashes itself, Money prevents negative amounts. Use judgment: if the value object just holds data, use a plain object. If it enforces rules, use a class.
What if my use case needs to call another use case?
Call it directly. Use cases can compose: RegisterUserAndSendWelcomeEmail might call RegisterUser internally. Keep the dependency graph acyclic: use cases form a DAG where lower-level use cases don't call higher-level ones.
Can presentation components call infrastructure directly?
No. Always go through application. This keeps presentation testable and domain logic reusable. Violating this rule eventually leads to duplicated business logic.
How do I migrate an existing flat codebase to layered architecture?
Start with the domain: identify entities and value objects, extract business rules. Then build the application layer: use cases that orchestrate domain. Then migrate components to presentation: make them thin, move logic down. Do this one use case at a time, testing as you go.