The Problem With Monolithic Frontends
Every engineering team hits the same wall. The codebase started clean -- well-organized components, sensible folder structure, a clear mental model. Six months and twelve features later, that same codebase is a tangled dependency graph. Changing the shipping form breaks the cart summary. Updating the payment component cascades into three unrelated pages. The components/ directory has grown into a dumping ground with no clear boundaries.
This is not a skill issue. It is an architecture issue. Monolithic frontends fail because they lack enforced boundaries. Every component can import every other component. Every hook can reach into any store slice. The absence of structural discipline means that over time, entropy wins.
Composable architecture is the antidote. It applies the same principles that make microservices work on the backend -- bounded contexts, defined contracts, independent deployability -- to the frontend. But unlike micro-frontends, which solve this at the application level, composable architecture operates at the component and module level. You get the benefits of isolation without the operational overhead of multiple deployed applications.
What Composable Architecture Actually Means
A composable system exhibits three non-negotiable properties.
Small Units With Single Responsibilities
Each piece does exactly one thing and does it well. A DatePicker does not format phone numbers. A CouponInput does not fetch product data. The boundary of responsibility is narrow enough that you can describe what a component does in a single sentence without using the word "and."
Standard Interfaces
Components communicate through well-defined contracts. In TypeScript, this means explicit prop types, generic type parameters where appropriate, and callback signatures that follow consistent patterns. A composable component does not reach into its parent's internals. It does not import store slices directly. It accepts what it needs through its props and notifies the outside world through callbacks.
True Isolation
Changing one component does not break another. This is not politeness -- it is enforced by the architecture. When a component does not import shared state directly, when it does not reach into global stores, when it defines its own data contracts, the blast radius of any change is contained to that component and its direct consumers. This is the frontend equivalent of the principle of least privilege.
// A truly composable component defines its own contract
interface CartSummaryProps {
items: CartItem[];
currency: CurrencyCode;
onApplyCoupon: (code: string) => Promise<CouponResult>;
onRemoveItem: (itemId: string) => void;
className?: string;
}
Micro-Frontends vs. Composable Components
These two approaches are often confused. They serve different purposes and operate at different scales.
| Aspect | Micro-Frontends | Composable Components |
|---|---|---|
| Deployment | Independent deploys per team | Single deploy, shared build |
| Communication | Events, shared shell, or iframes | Props, callbacks, context |
| Team scope | Multiple teams | Single team or squad |
| Bundle size | Per-app bundles (duplication risk) | Shared bundling (tree-shakeable) |
| Sharing | Shared component library needed | Implicit via package |
| Best for | Large orgs, multiple product teams | Single product, component library |
Composable architecture is the right choice for the vast majority of teams. Micro-frontends solve organizational problems -- multiple teams owning different parts of a page. Composable components solve engineering problems -- building features that are maintainable, testable, and reusable.
Feature Slicing: Organizing by Domain
Feature slicing is the organizational pattern that makes composability work in practice. Instead of grouping files by type (all components here, all hooks there, all utilities over there), you group them by domain feature.
src/
features/
checkout/
components/
CartSummary.tsx
CartSummary.test.tsx
ShippingForm.tsx
ShippingForm.test.tsx
PaymentForm.tsx
OrderReview.tsx
hooks/
useShippingRates.ts
usePaymentValidation.ts
types/
index.ts
index.ts <- barrel export
products/
components/
ProductCard.tsx
ProductGrid.tsx
ProductFilters.tsx
hooks/
useProducts.ts
useProductSearch.ts
types/
index.ts
index.ts
shared/
ui/
Button.tsx
Input.tsx
Modal.tsx
utils/
formatCurrency.ts
validateEmail.ts
Each feature folder is self-contained. It exports a clean public API through its index.ts barrel. Nothing outside the feature imports from features/checkout/components/CartSummary.tsx directly -- it goes through the barrel. This makes it possible to refactor an entire feature without touching the rest of the application.
// features/checkout/index.ts -- public API only
export { CartSummary } from './components/CartSummary';
export { ShippingForm } from './components/ShippingForm';
export { PaymentForm } from './components/PaymentForm';
export { useShippingRates } from './hooks/useShippingRates';
export type { CheckoutState, ShippingOption, PaymentMethod } from './types';
Shared Libraries: Building Your Platform
Every composable system needs a foundation of shared primitives. These are not feature code -- they are the building blocks that features use. The key discipline is that shared libraries must never import from features. That dependency arrow points one way only.
UI Primitives
A shared component library with unstyled or minimally styled primitives. These handle accessibility, keyboard navigation, focus management, and ARIA attributes. Features compose these into domain-specific components.
// shared/ui/Select.tsx -- accessible, composable primitive
interface SelectProps<T extends string> {
options: { value: T; label: string }[];
value: T;
onChange: (value: T) => void;
placeholder?: string;
error?: string;
disabled?: boolean;
}
Utility Functions
Pure functions for formatting, validation, and data transformation. These are the most reusable layer because they carry no UI or framework dependencies.
Hooks and Providers
Shared React hooks for common concerns -- media queries, intersection observers, form state management, API calls. These are framework integrations that features consume.
Dependency Inversion at the Component Level
Dependency inversion is the most powerful pattern in composable architecture. The principle is simple: high-level modules should not depend on low-level modules. Both should depend on abstractions.
Applied to React components, this means a feature component does not import your API client, your database, or your global store directly. It defines an interface -- a set of props and callbacks -- and the composition root provides the implementations.
// Inside the feature -- defines the interface
interface PaymentFormProps {
onSubmit: (payment: PaymentDetails) => Promise<PaymentResult>;
onValidate: (details: Partial<PaymentDetails>) => ValidationResult;
availableMethods: PaymentMethod[];
}
// At the composition root -- provides the implementation
<PaymentForm
onSubmit={(details) => apiClient.createPayment(details)}
onValidate={(details) => paymentValidator.validate(details)}
availableMethods={paymentMethods}
/>
This is not ceremony. This is what makes the component testable without mocking a database, deployable in any context, and resilient to backend changes. When your API client changes from REST to GraphQL, the PaymentForm does not change. Its contract is stable.
Dependency Injection via Composition Root
The composition root is the one place in your application where wiring happens. In Next.js, this is typically the page component or a layout.
// pages/checkout/index.tsx -- the composition root
export default function CheckoutPage() {
const { cart, shipping, payment, submitOrder } = useCheckout();
return (
<div>
<CartSummary
items={cart.items}
currency={cart.currency}
onApplyCoupon={cart.applyCoupon}
onRemoveItem={cart.removeItem}
/>
<ShippingForm
onShippingSelected={shipping.selectOption}
defaultAddress={shipping.defaultAddress}
/>
<PaymentForm
onSubmit={payment.submit}
onValidate={payment.validate}
availableMethods={payment.methods}
/>
<OrderReview
items={cart.items}
shipping={shipping.selectedOption}
payment={payment.selectedMethod}
onPlaceOrder={submitOrder}
/>
</div>
);
}
Every dependency is explicit. Every data flow is visible. If you need to swap the payment provider, you change the composition root, not the PaymentForm component.
Event-Driven Communication Between Independent Pieces
When two composable pieces need to communicate without knowing about each other, use events. This is common in dashboards, real-time feeds, and multi-step flows where one component's action affects another's state.
// Event bus -- lightweight, typed
type CheckoutEvent =
| { type: 'COUPON_APPLIED'; code: string; discount: number }
| { type: 'SHIPPING_SELECTED'; option: ShippingOption; cost: number }
| { type: 'PAYMENT_COMPLETED'; orderId: string };
const checkoutBus = createEventBus<CheckoutEvent>();
// Component A publishes
const CouponInput = ({ onApply }: CouponInputProps) => {
const handleApply = async (code: string) => {
const result = await applyCoupon(code);
checkoutBus.emit({
type: 'COUPON_APPLIED',
code,
discount: result.discount,
});
onApply(result);
};
};
// Component B subscribes -- no direct import needed
const CartSummary = ({ items }: CartSummaryProps) => {
useEffect(() => {
const unsubscribe = checkoutBus.on('COUPON_APPLIED', (event) => {
// Recalculate totals with discount
});
return unsubscribe;
}, []);
};
For simpler cases, prop drilling through the composition root is sufficient. Reach for events only when the communicating components are in separate feature slices and the composition root is too far away to be a convenient intermediary.
Real Project Example: Checkout System
Here is how composable architecture played out on a recent WEIRDSOFT project -- a high-volume e-commerce checkout handling 50,000+ transactions per day.
The Architecture
The checkout page is a composition of four independent feature components:
pages/checkout/index.tsx (composition root)
|-- features/checkout/CartSummary
| +-- Depends on: shared/ui, shared/utils
|-- features/checkout/ShippingForm
| +-- Depends on: shared/ui, shared/utils
|-- features/checkout/PaymentForm
| +-- Depends on: shared/ui, shared/utils
+-- features/checkout/OrderReview
+-- Depends on: shared/ui, shared/utils
Note that none of the feature components depend on each other. Each one receives its data and callbacks from the composition root.
Data Flow
The data flow follows a unidirectional pattern:
- User interacts with a feature component (e.g., applies a coupon)
- Component calls its callback prop (
onApplyCoupon) - Composition root executes the business logic (API call, state update)
- New data flows down through props to all affected components
This pattern means you can reason about data flow by reading one file -- the composition root. You do not need to trace through a maze of imports and context providers.
The Outcome
| Metric | Before (Monolith) | After (Composable) |
|---|---|---|
| New feature delivery | 2-3 weeks | 3-5 days |
| Test coverage | 34% | 91% |
| Component reuse | 2 shared components | 14 shared components |
| Time to onboard new dev | 2 weeks | 3 days |
| Bug rate per sprint | 8-12 | 1-3 |
Testing Strategy for Composable Systems
Composable architecture unlocks testing strategies that are impractical in monolithic codebases.
Unit Tests: Component-Level Isolation
Every composable component is independently testable because it defines its own contract. No mocking of global state. No complex setup.
describe('CartSummary', () => {
const defaultProps: CartSummaryProps = {
items: mockCartItems,
currency: 'USD',
onApplyCoupon: vi.fn(),
onRemoveItem: vi.fn(),
};
it('displays item count', () => {
render(<CartSummary {...defaultProps} />);
expect(screen.getByText('3 items')).toBeInTheDocument();
});
it('calls onApplyCoupon when coupon code is submitted', async () => {
render(<CartSummary {...defaultProps} />);
await user.type(screen.getByLabelText('Coupon code'), 'SAVE20');
await user.click(screen.getByRole('button', { name: 'Apply' }));
expect(defaultProps.onApplyCoupon).toHaveBeenCalledWith('SAVE20');
});
});
Integration Tests: Composition Root
Test the composition root with real component integrations but mocked data layer. This validates that components work together without testing the backend.
describe('CheckoutPage integration', () => {
it('flows from cart to payment', async () => {
render(<CheckoutPage />);
await user.click(screen.getByRole('button', { name: 'Proceed to Shipping' }));
expect(screen.getByTestId('shipping-form')).toBeInTheDocument();
});
});
E2E Tests: Critical Paths
A small set of end-to-end tests covers the critical user journeys. Composability means these tests are more stable -- when a component changes, its public interface (which the E2E tests interact with) rarely changes.
Contract Tests
For shared libraries and feature boundaries, contract tests verify that components adhere to their defined interfaces:
describe('CartSummary contract', () => {
it('accepts all required props', () => {
// TypeScript checks this at compile time
// Runtime checks validate the contract at test time
});
it('does not import from features outside its domain', () => {
// Enforce with ESLint import restrictions
});
});
Configure ESLint to enforce feature boundary rules:
{
"rules": {
"import/no-restricted-paths": [
"error",
{
"zones": [
{
"target": "src/features/checkout",
"from": "src/features/products",
"message": "Checkout must not import from products"
}
]
}
]
}
}
Getting Started With Composability
You do not need to rewrite your application overnight. Start with one feature.
- Identify a bounded feature -- something with clear inputs and outputs. The checkout flow, the search interface, the user profile settings.
- Define its public interface -- what props does it accept? What callbacks does it expose? Write the types first.
- Extract it behind a barrel export -- create a
feature/index.tsthat exports only the public API. - Move dependencies to the composition root -- instead of importing the API client or store directly, thread them through props.
- Add a test -- now that the component is isolated, writing a focused unit test is trivial.
Repeat for the next feature. Within a sprint or two, the pattern becomes self-reinforcing. New components naturally fit into the composable structure because every existing component demonstrates the pattern.
The best code is the code you do not write. Composable architecture makes sure that when you do write code, it fits, it works, and it stays out of the way of the next feature.