Architecture Patterns
Architecture Patterns
As applications grow, good architecture becomes essential. These patterns help you organize code in ways that scale with your team and complexity.
Project Structure Patterns
Feature-Based Structure
Organize by feature, not by file type.
src/
├── features/
│ ├── auth/
│ │ ├── components/
│ │ │ ├── LoginForm.tsx
│ │ │ ├── SignupForm.tsx
│ │ │ └── AuthProvider.tsx
│ │ ├── hooks/
│ │ │ ├── useAuth.ts
│ │ │ └── useSession.ts
│ │ ├── api/
│ │ │ └── authApi.ts
│ │ ├── types.ts
│ │ └── index.ts
│ │
│ ├── products/
│ │ ├── components/
│ │ ├── hooks/
│ │ ├── api/
│ │ └── index.ts
│ │
│ └── cart/
│ ├── components/
│ ├── hooks/
│ ├── store/
│ └── index.ts
│
├── shared/
│ ├── components/ # Reusable UI components
│ ├── hooks/ # Shared hooks
│ ├── utils/ # Utility functions
│ └── types/ # Shared types
│
├── app/ # Next.js App Router routes
└── lib/ # Third-party configurationsBarrel Exports
// features/auth/index.ts
export { LoginForm } from './components/LoginForm';
export { SignupForm } from './components/SignupForm';
export { AuthProvider } from './components/AuthProvider';
export { useAuth } from './hooks/useAuth';
export { useSession } from './hooks/useSession';
export type { User, Session, AuthState } from './types';
// Usage - clean imports
import { useAuth, LoginForm } from '@/features/auth';Layered Architecture
Separate concerns into distinct layers.
┌─────────────────────────────────────────┐
│ Presentation Layer │
│ (Components, Pages, UI) │
├─────────────────────────────────────────┤
│ Application Layer │
│ (Hooks, State, Business Logic) │
├─────────────────────────────────────────┤
│ Domain Layer │
│ (Entities, Value Objects, Rules) │
├─────────────────────────────────────────┤
│ Infrastructure Layer │
│ (API, Storage, External Services) │
└─────────────────────────────────────────┘// domain/entities/Order.ts
export class Order {
constructor(
public id: string,
public items: OrderItem[],
public status: OrderStatus,
public createdAt: Date
) {}
get total(): number {
return this.items.reduce((sum, item) =>
sum + item.price * item.quantity, 0
);
}
canCancel(): boolean {
return this.status === 'pending' || this.status === 'processing';
}
addItem(item: OrderItem): void {
if (this.status !== 'pending') {
throw new Error('Cannot modify non-pending order');
}
this.items.push(item);
}
}
// infrastructure/api/orderApi.ts
export const orderApi = {
async getOrder(id: string): Promise<Order> {
const response = await fetch(`/api/orders/${id}`);
const data = await response.json();
return new Order(data.id, data.items, data.status, new Date(data.createdAt));
},
async createOrder(items: OrderItem[]): Promise<Order> {
const response = await fetch('/api/orders', {
method: 'POST',
body: JSON.stringify({ items })
});
const data = await response.json();
return new Order(data.id, data.items, data.status, new Date(data.createdAt));
}
};
// application/hooks/useOrder.ts
export function useOrder(orderId: string) {
const [order, setOrder] = useState<Order | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
orderApi.getOrder(orderId)
.then(setOrder)
.finally(() => setLoading(false));
}, [orderId]);
const cancelOrder = useCallback(async () => {
if (!order?.canCancel()) {
throw new Error('Order cannot be cancelled');
}
await orderApi.cancelOrder(orderId);
// Refresh order
}, [order, orderId]);
return { order, loading, cancelOrder };
}
// presentation/components/OrderDetails.tsx
export function OrderDetails({ orderId }: { orderId: string }) {
const { order, loading, cancelOrder } = useOrder(orderId);
if (loading) return <Skeleton />;
if (!order) return <NotFound />;
return (
<div>
<h1>Order #{order.id}</h1>
<p>Total: ${order.total}</p>
<p>Status: {order.status}</p>
{order.canCancel() && (
<button onClick={cancelOrder}>Cancel Order</button>
)}
</div>
);
}Module Boundaries
As codebases grow, developers start importing from anywhere. Component A imports a utility from Feature B, which imports a hook from Feature C. Soon, everything depends on everything—changing one thing breaks ten others.
Module boundaries solve this by defining clear public APIs for each module. Think of each feature as a library with an index.ts that exports only what other modules should use.
Benefits:
- Easier refactoring (internals can change without affecting others)
- Clear ownership (who's responsible for what)
- Faster builds (changes to internals don't require recompiling dependents)
- Better code review (changes to public API get more scrutiny)
The pattern:
- Each module has a single entry point (
index.ts) - Only explicitly exported items are public API
- Everything else is internal implementation
- Enforce with ESLint rules
// features/products/index.ts - PUBLIC API
// Only export what other modules should use
export { ProductCard } from './components/ProductCard';
export { ProductList } from './components/ProductList';
export { useProducts } from './hooks/useProducts';
export { useProduct } from './hooks/useProduct';
export type { Product, ProductCategory } from './types';
// Internal implementation details stay private
// features/products/utils/transformers.ts - NOT EXPORTED
// features/products/components/ProductImage.tsx - NOT EXPORTED
// Enforce with ESLint
// .eslintrc.js
module.exports = {
rules: {
'no-restricted-imports': [
'error',
{
patterns: [
{
group: ['@/features/*/components/*', '!@/features/*/index'],
message: 'Import from feature index file instead'
}
]
}
]
}
};Dependency Injection
Dependency Injection (DI) means passing dependencies to a function/class rather than creating them inside. This simple change has profound implications for testability and flexibility.
The problem without DI:
- Functions create their own dependencies internally
- You can't swap implementations (e.g., for testing)
- Changing a dependency requires modifying the consumer
- Testing requires mocking modules (fragile)
The solution with DI:
- Dependencies are passed in (injected)
- You can inject mocks for testing
- You can swap implementations without changing consumers
- Dependencies are explicit and documented
In frontend code:
- Pass API clients to services
- Pass services to hooks/components via Context
- Pass configuration as parameters
// Without DI - hard to test, tightly coupled
function UserService() {
async function getUser(id) {
const response = await fetch(`/api/users/${id}`); // Hardcoded
return response.json();
}
return { getUser };
}
// With DI - testable, flexible
function createUserService(httpClient) {
async function getUser(id) {
return httpClient.get(`/users/${id}`);
}
return { getUser };
}
// Production
const httpClient = createHttpClient({ baseUrl: "/api" });
const userService = createUserService(httpClient);
// Testing
const mockClient = { get: jest.fn().mockResolvedValue({ id: 1 }) };
const userService = createUserService(mockClient);React Context for DI
React Context is a natural fit for dependency injection—it provides a way to pass dependencies down the component tree without prop drilling.
The pattern:
- Create services at the app root
- Provide them via Context
- Components consume services via custom hook
- Swap services in tests by providing different Context value
// services/ServiceProvider.tsx
const ServiceContext = createContext(null);
export function ServiceProvider({ children, services }) {
return (
<ServiceContext.Provider value={services}>
{children}
</ServiceContext.Provider>
);
}
export function useServices() {
const context = useContext(ServiceContext);
if (!context) {
throw new Error("useServices must be used within ServiceProvider");
}
return context;
}
// services/createServices.ts
export function createServices(config) {
const httpClient = createHttpClient(config.apiUrl);
const authService = createAuthService(httpClient);
const userService = createUserService(httpClient);
const productService = createProductService(httpClient);
return {
auth: authService,
users: userService,
products: productService,
};
}
// App.tsx
function App() {
const services = useMemo(
() =>
createServices({
apiUrl: process.env.NEXT_PUBLIC_API_URL,
}),
[],
);
return (
<ServiceProvider services={services}>
<AppContent />
</ServiceProvider>
);
}
// Usage in components
function UserProfile({ userId }) {
const { users } = useServices();
const [user, setUser] = useState(null);
useEffect(() => {
users.getUser(userId).then(setUser);
}, [userId, users]);
return <Profile user={user} />;
}Repository Pattern
The Repository Pattern abstracts data access behind a consistent interface. Instead of components calling fetch('/api/users') directly, they call userRepository.findAll().
Why this matters:
- Swappable backends: Change from REST to GraphQL without touching components
- Testable: Inject mock repositories for testing
- Centralized logic: Caching, error handling, data transformation in one place
- Type safety: Repository interface defines exactly what's possible
When to use:
- Applications with complex data access patterns
- When you might change data sources
- When you want consistent data access across the app
- When testing data-dependent components
// repositories/types.ts
interface Repository<T> {
findById(id: string): Promise<T | null>;
findAll(query?: Query): Promise<T[]>;
create(data: Partial<T>): Promise<T>;
update(id: string, data: Partial<T>): Promise<T>;
delete(id: string): Promise<void>;
}
// repositories/userRepository.ts
export function createUserRepository(httpClient): Repository<User> {
return {
async findById(id) {
try {
return await httpClient.get(`/users/${id}`);
} catch (error) {
if (error.status === 404) return null;
throw error;
}
},
async findAll(query = {}) {
const params = new URLSearchParams(query);
return httpClient.get(`/users?${params}`);
},
async create(data) {
return httpClient.post('/users', data);
},
async update(id, data) {
return httpClient.patch(`/users/${id}`, data);
},
async delete(id) {
await httpClient.delete(`/users/${id}`);
}
};
}
// Can swap implementations easily
export function createMockUserRepository(): Repository<User> {
const users = new Map<string, User>();
return {
async findById(id) {
return users.get(id) || null;
},
// ... mock implementations
};
}Command Query Separation (CQS)
CQS is a simple principle: methods should either read data (Query) or change data (Command), never both.
- Queries: Return data, have no side effects, can be called multiple times safely
- Commands: Perform actions, may have side effects, change system state
Why this helps:
- Queries can be cached aggressively (they never change data)
- Commands clearly indicate something will change
- Easier to reason about what code does
- Works naturally with React Query's
useQuery/useMutation
In practice with React Query:
useQueryfor queries (GET requests, reading data)useMutationfor commands (POST/PUT/DELETE, changing data)- Commands invalidate relevant queries after success
// queries - return data, no side effects
export const queries = {
useUser(id: string) {
return useQuery(['user', id], () => userApi.getUser(id));
},
useUsers(filters: UserFilters) {
return useQuery(['users', filters], () => userApi.getUsers(filters));
},
useUserOrders(userId: string) {
return useQuery(['user-orders', userId], () => orderApi.getByUser(userId));
}
};
// commands - perform actions, may have side effects
export const commands = {
useCreateUser() {
const queryClient = useQueryClient();
return useMutation(
(data: CreateUserData) => userApi.create(data),
{
onSuccess: () => {
queryClient.invalidateQueries(['users']);
}
}
);
},
useUpdateUser() {
const queryClient = useQueryClient();
return useMutation(
({ id, data }: { id: string; data: UpdateUserData }) =>
userApi.update(id, data),
{
onSuccess: (_, { id }) => {
queryClient.invalidateQueries(['user', id]);
queryClient.invalidateQueries(['users']);
}
}
);
},
useDeleteUser() {
const queryClient = useQueryClient();
return useMutation(
(id: string) => userApi.delete(id),
{
onSuccess: () => {
queryClient.invalidateQueries(['users']);
}
}
);
}
};
// Usage
function UserManager() {
const { data: users } = queries.useUsers({ active: true });
const createUser = commands.useCreateUser();
const deleteUser = commands.useDeleteUser();
return (
<div>
<UserForm onSubmit={createUser.mutate} />
<UserList
users={users}
onDelete={(id) => deleteUser.mutate(id)}
/>
</div>
);
}Event-Driven Architecture
In tightly coupled systems, components call each other directly. When User logs out, the AuthService must know about CartService, AnalyticsService, WebSocketService—and call each one.
Event-driven architecture inverts this relationship. AuthService just announces "user logged out." Any interested service listens and responds. AuthService doesn't know or care who's listening.
Benefits:
- Loose coupling: Publisher doesn't know about subscribers
- Extensibility: Add new behaviors without modifying existing code
- Testability: Test components in isolation
- Flexibility: Enable/disable features by adding/removing listeners
When to use:
- Cross-cutting concerns (analytics, logging, error tracking)
- Actions that trigger multiple independent effects
- When you want to avoid circular dependencies
- For plugin-like architectures
// events/eventBus.ts
type EventHandler<T = any> = (payload: T) => void;
class EventBus {
private handlers = new Map<string, Set<EventHandler>>();
on<T>(event: string, handler: EventHandler<T>): () => void {
if (!this.handlers.has(event)) {
this.handlers.set(event, new Set());
}
this.handlers.get(event)!.add(handler);
// Return unsubscribe function
return () => this.handlers.get(event)?.delete(handler);
}
emit<T>(event: string, payload?: T): void {
this.handlers.get(event)?.forEach(handler => {
try {
handler(payload);
} catch (error) {
console.error(`Error in event handler for ${event}:`, error);
}
});
}
once<T>(event: string, handler: EventHandler<T>): () => void {
const unsubscribe = this.on(event, (payload: T) => {
unsubscribe();
handler(payload);
});
return unsubscribe;
}
}
export const eventBus = new EventBus();
// Define event types
export const AppEvents = {
USER_LOGGED_IN: 'user:loggedIn',
USER_LOGGED_OUT: 'user:loggedOut',
CART_UPDATED: 'cart:updated',
ORDER_PLACED: 'order:placed',
NOTIFICATION: 'ui:notification'
} as const;
// Usage - producer
function AuthService() {
async function login(credentials) {
const user = await api.login(credentials);
eventBus.emit(AppEvents.USER_LOGGED_IN, { user });
return user;
}
async function logout() {
await api.logout();
eventBus.emit(AppEvents.USER_LOGGED_OUT);
}
}
// Usage - consumer
function AnalyticsTracker() {
useEffect(() => {
const unsub1 = eventBus.on(AppEvents.USER_LOGGED_IN, ({ user }) => {
analytics.identify(user.id);
});
const unsub2 = eventBus.on(AppEvents.ORDER_PLACED, ({ order }) => {
analytics.track('purchase', { orderId: order.id, total: order.total });
});
return () => {
unsub1();
unsub2();
};
}, []);
return null;
}Micro-Frontends
As frontend applications grow, they face the same scaling challenges as backend monoliths:
- Teams step on each other's toes
- Deployments require coordinating many people
- Changes in one area break other areas
- Tech stack upgrades are all-or-nothing
Micro-frontends apply microservice principles to the frontend: split the UI into independently developed, deployed, and maintained pieces.
When micro-frontends make sense:
- Very large teams (multiple squads working on same product)
- Different parts of the app need different release cadences
- You want to migrate incrementally (old tech → new tech)
- Teams need true autonomy
When to avoid:
- Small teams (overhead isn't worth it)
- Consistent UX is critical (harder to maintain across microfrontends)
- Simple applications
Implementation approaches:
- Module Federation (Webpack 5) - Load remote modules at runtime
- iframes - Strongest isolation, worst UX
- Web Components - Framework-agnostic components
- Build-time integration - Combine at CI/CD time
// Module Federation (Webpack 5)
// host/webpack.config.js
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: "host",
remotes: {
products: "products@http://localhost:3001/remoteEntry.js",
cart: "cart@http://localhost:3002/remoteEntry.js",
checkout: "checkout@http://localhost:3003/remoteEntry.js",
},
shared: ["react", "react-dom"],
}),
],
};
// products/webpack.config.js
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: "products",
filename: "remoteEntry.js",
exposes: {
"./ProductList": "./src/ProductList",
"./ProductDetail": "./src/ProductDetail",
},
shared: ["react", "react-dom"],
}),
],
};
// host/App.tsx
const ProductList = React.lazy(() => import("products/ProductList"));
const Cart = React.lazy(() => import("cart/Cart"));
function App() {
return (
<Suspense fallback={<Loading />}>
<Routes>
<Route path="/products" element={<ProductList />} />
<Route path="/cart" element={<Cart />} />
</Routes>
</Suspense>
);
}Configuration Management
Configuration scattered across the codebase leads to inconsistency and hard-to-find bugs. Centralized configuration provides:
- Single source of truth for all config values
- Type safety (TypeScript catches typos)
- Validation (fail fast if required config is missing)
- Documentation (all config in one place)
What belongs in configuration:
- API URLs and endpoints
- Feature flags
- Third-party service keys (public ones)
- Timeouts and retry settings
- Environment-specific values
Best practices:
- Validate config at startup (fail fast)
- Use
as constfor type inference - Provide sensible defaults
- Group related config together
- Document required vs optional values
// config/index.ts
const config = {
api: {
baseUrl: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api',
timeout: parseInt(process.env.NEXT_PUBLIC_API_TIMEOUT || '30000'),
},
features: {
darkMode: process.env.NEXT_PUBLIC_FEATURE_DARK_MODE === 'true',
analytics: process.env.NEXT_PUBLIC_FEATURE_ANALYTICS === 'true',
beta: process.env.NEXT_PUBLIC_FEATURE_BETA === 'true',
},
sentry: {
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
environment: process.env.NODE_ENV,
}
} as const;
// Validate required config
function validateConfig() {
const required = ['api.baseUrl'];
for (const path of required) {
const value = path.split('.').reduce((obj, key) => obj?.[key], config);
if (value === undefined) {
throw new Error(`Missing required config: ${path}`);
}
}
}
validateConfig();
export { config };
// Usage
import { config } from '@/config';
fetch(`${config.api.baseUrl}/users`);
if (config.features.analytics) {
initAnalytics();
}Summary
| Pattern | Purpose | When to Use |
|---|---|---|
| Feature-based structure | Organize by domain | Medium to large apps |
| Layered architecture | Separate concerns | Complex business logic |
| Module boundaries | Encapsulation | Team scaling |
| Dependency injection | Testability, flexibility | Always |
| Repository pattern | Abstract data access | Multiple data sources |
| CQS | Separate reads/writes | Complex state |
| Event-driven | Loose coupling | Cross-cutting concerns |
| Micro-frontends | Independent deployment | Very large apps |
Key Principles:
- Start simple, add architecture as complexity grows
- Make dependencies explicit
- Define clear module boundaries
- Separate what changes for different reasons
- Optimize for deletion—make code easy to remove