How I took a frontend that grew without a plan, measured the mess, and moved
it to clean architecture in eleven small PRs, finding real bugs along the way.
When I started building our markets-data platform, I didn't think about architecture. I focused on getting equities, indices, and screen prices.
It's a Next.js App Router project that shows stock exchange data, news, watchlists, and paid subscriptions.
That worked until it didn't. As the app grew, I adopted clean architecture for new features: entities in a domain layer, use cases in an application layer, repositories in infrastructure. The older code kept doing what it had always done. Server actions inside route folders called the backend with axios. Business logic lived in app/(subscription)/lib.
There were two places for shared components, two for hooks, and four for a utility function.
I ended up with a hybrid. Worse, even the "clean" parts leaked: the domain imported types from the application layer, and use cases were typed against
concrete API classes rather than interfaces.
Here is how I fixed it without a rewrite or freezing feature work.
Why bother with clean architecture in a frontend?
The core idea fits in one sentence: source code dependencies only point
inwards.
UI (routes, components, server actions)
→ presentation (mappers, formatters)
→ application (use cases)
→ domain (entities, repository interfaces)
Infrastructure (HTTP clients, repositories, config) sits on the side,
implementing interfaces the domain defines.
The domain doesn't know React exists. A use case doesn't know whether its repository calls a REST API, reads localStorage or is a test fake. A component doesn't know which endpoint its data came from.
That pays off in three practical ways:
- Backend churn stops at one file. When the API renames a field, the DTO that maps the response changes and nothing else does.
- Logic is testable without mocking the world. A use case test needs a three-line fake, not an HTTP mock.
- Details are swappable. I moved "recently viewed instruments" from an API to
localStorage by writing one new repository. The use case and UI didn't change.
Step 1: Measure before you move anything
The temptation with a messy codebase is to start moving files. I started with an audit instead, sorting every problem I could find into six buckets:
- Dependency-rule violations inside the "clean" layers
- Duplicated core concepts (two
APIError classes, two use case styles) - Data access that bypassed the architecture entirely
- Business logic living in the routing layer
- Dead code
- Folder sprawl
Then, before touching any of it, I encoded the architecture as lint
rules. eslint-config-next already ships eslint-plugin-import, whose import/no-restricted-paths rule checks resolved paths. It catches @/infrastructure/... and ../../../infrastructure/... alike.
const coreZones = [
{
target: "./src/domain",
from: ["./src/application", "./src/infrastructure", "./src/presentation", ...UI],
message: "Domain must not depend on other layers.",
},
{
target: "./src/application",
from: ["./src/infrastructure", "./src/presentation", ...UI],
message: "Depend on a domain interface, not an implementation.",
},
// …presentation, infrastructure, shared, and "src/app is for routes only"
];
Every rule's message tells the developer how to fix the problem, not just that one exists.
The first run produced 120 boundary violations. That number became the most useful thing in the whole project. "The codebase is messy" is an opinion; "120 violations, down to 82 this week" is progress you can see.
Side quest: on Next.js 16, next lint no longer exists, and my old
FlatCompat-based config crashed with "Converting circular structure to
JSON". Switching to the native flat configs that eslint-config-next now
ships fixed it. Lint had been quietly not running.
Step 2: Warnings now, errors later
If I'd introduced those rules as errors, every PR in the repo would have failed on day one. So they started as warnings.
The trick is the second half: when a layer reaches zero violations, its rules become errors in the same PR.
{
// Core layers and shared code are clean, so new violations there fail lint
files: ["src/domain/**", "src/application/**", "src/presentation/**",
"src/infrastructure/**", "src/shared/**"],
rules: { "import/no-restricted-paths": ["error", { zones: coreZones }] },
}
Each cleaned-up layer stayed clean. Progress couldn't slide back quietly.
Step 3: Fix the core first
I started in the middle, with domain and application, because everything else depends on them.
The most instructive bug was the simplest. There were two identical
APIError classes, one in the domain and one in infrastructure. They had the same code, line for line. But an error thrown by a repository using one class failed instanceof against the other, and the user saw "An unknown error occurred" instead of the real message. Duplicated core types aren't just untidy. They break things in ways that are hard to trace.
The rest of the phase was mostly moving things to where they belonged:
- Raw API response shapes (
snake_case, wrapped in{ success, data, error }) moved from the application layer to infrastructure. The domain stopped importing them. - Domain enums moved out of
infrastructure/types/enums into the domain. - Use cases were retyped against domain interfaces instead of concrete
*RepositoryApi classes. - Formatters, which decide how a price or date is displayed, moved from infrastructure into presentation.
Step 4: Give the wiring a home
Once use cases depended on interfaces, something had to create the concrete classes and pass them in. Before, sixteen server action files each did it themselves, every one with its own copy of the same error-handling lambda.
I replaced that with a composition root: one file that creates every repository and use case.
// src/infrastructure/di/server.ts
import "server-only";
export function toUserError(error: unknown) {
if (error instanceof APIError) {
return { message: error.getUserFriendlyMessage(), statusCode: error.getStatusCode() };
}
return { message: "An unknown error occured", statusCode: 500 };
}
export const currencyUseCase = new CurrencyUseCase(new CurrencyRepositoryApi());
export const equityUseCase = new EquityUseCase(
new EquityRepositoryApi(),
new SystemDateProvider(),
toUserError,
);
Server actions shrank to almost nothing:
"use server";
import { currencyUseCase } from "@/infrastructure/di/server";
export async function getPairConversion(base: string, target: string, amount: number) {
return currencyUseCase.convert(base, target, amount);
}
The import "server-only" line matters. These repositories send an API key and the user's auth cookie. If a client component ever imports the container, the build fails.
The day the plan was wrong
My plan said to route geolocation through a server action like everything else. Fortunately, I stopped to check what the location API actually does.
It works out the country from the IP address of whoever calls it. From the server, every visitor would have been located at our server, and shown that region's subscription prices and payment methods.
So there's a second, deliberately small container for things that must
run in the browser:
// src/infrastructure/di/client.ts
// Runs in the browser so the lookup sees the visitor's IP
export const geolocationUseCase = new GeolocationUseCase(new GeolocationRepositoryApi());
The layers didn't change. Only where the code runs did. Clean architecture told me where the code belongs, but not where it executes. That still takes thought.
Step 5: Migrate one slice at a time
The legacy areas moved one product area per PR: auth, subscriptions and payments, currencies, news, geolocation. Each one was tested by hand where possible and with unit tests where not.
My favourite example is the news feed. The old flow was:
- The browser fetched our own
/feed.xml route. - That route downloaded the publisher's RSS, parsed it… and turned it back into XML.
- The browser then parsed that XML a second time with
rss-parser.
After the migration, an RssFeedRepositoryApi parses each feed once, on the server. The browser gets plain article objects from a server action, and rss-parser dropped out of the client bundle. /feed.xml still exists for RSS readers, built from the same use case.
The part that took discipline was keeping the output identical. The home page displays the raw pubDate string, so the new mapper reproduces the old format exactly (toUTCString()). Users shouldn't notice an architecture change.
Step 6: Consolidate, and audit what reaches the browser
The last phase merged the leftover folders (components, hooks, lib, utils, types) into shared or the feature that owned them. Two rules decided where things went:
- Shared code may not depend on features. The navbar needs auth and subscription state, so it went to
features/navigation, not shared. - Look-alikes aren't duplicates until proven identical. Two
DatePickers and two useFilters hooks turned out to differ in styling, page size, and default values. Merging them would have changed behaviour for one set of users. They kept separate, clearly named homes.
That phase also caught the most important bug of the project.
In Next.js, a NEXT_PUBLIC_* variable is inlined into any browser bundle whose code references it. Our config module referenced some sensitive values under that prefix, and because browser code imported the config, those values ended up in client JavaScript. That was true even though no browser code used those particular fields.
The fix was structural, not a one-off:
- a small
shared/config/public.ts with only the values a visitor may see - the full config marked
server-only, so the mistake can't come back quietly - a check after each build: search
.next/static for the secret values (not their names), and expect zero matches
Moving code is not the same as preserving behaviour
If I had to pick one lesson, it's this one. Several "obvious" changes turned out to be wrong when I actually checked:
- Geolocation on the server would have shown everyone the wrong prices.
- Merging the look-alike components would have changed table sizes and defaults.
- "Fixing" an RSS image bug could have crashed news pages. The parser renamed
media:content before the code looked for it, so those images were never used. But next/image only allowed one image host, so picking up more image URLs could have introduced hosts it rejects. I checked the live feeds first, then made the fix to filter images through the same host allowlist next.config.ts uses.
Architecture work finds bugs
Pushing code through proper layers forces you to read it. Along the way, I found:
- a reset-password action that never awaited its request, so it always said "success"
- a "Try again" button that called
router.refresh(), which never retries a failed client-side React Query request - the image lookup that could never match
- sensitive config inlined into the browser bundle
None of these came up in normal feature work. They all came up because a refactor made someone look closely at the code.
Untestable by hand? Then test it properly
Some flows were hard to exercise manually: coupons need a valid code, live price highlighting needs the market open, error states need an API to fail.
Rather than mark them "tested", I covered them with unit tests. Because each use case depends on an interface, the fakes are trivial:
const useCaseWith = (convert: ICurrencyRepository["convert"]) =>
new CurrencyUseCase({ convert });
it("rejects missing input without calling the API", async () => {
const convert = jest.fn();
const result = await useCaseWith(convert).convert("", "KES", 2);
expect(convert).not.toHaveBeenCalled();
expect(result.error?.errorType).toBe("invalid-input");
});
For the important ones, I also ran a quick mutation check: break the code on purpose, confirm the test fails, then restore it. A test that can't fail is worse than no test, because it gives you confidence you haven't earned.
Know when not to add a layer
Not everything got the full treatment. The auth API routes forward
credentials and cookies to the backend and holds no business rules, so they call an AuthGatewayApi directly, with no use case in between. A use case there would only pass values through.
The same goes for the old local login and profile pages. Authentication now happens entirely through SSO, but I kept those pages as a fallback in case we ever need to revert. They're documented as a deliberate exception and still show up in lint, so they stay in view.
Clean architecture is a means to an end, not a checklist.
The results
BeforeAfterLayer-boundary violations1204 (all deliberate)Layers where a violation fails lintnone5Lint warnings13417 (0 errors)Tests4077Top-level
src folders138Places that build use cases182Sensitive values in the browser bundlesomenone, with a build-time guard
Eleven PRs, each one reviewable and shippable on its own, with no feature freeze.
If you're about to do the same
- Audit, then encode the rules in lint before moving code. You need a number.
- Start with warnings, and turn each clean layer's rules into errors.
- Fix the core first. Everything else depends on it.
- Give your wiring one home, and make it
server-only if it holds anything sensitive. - Migrate one slice per PR, and keep the output identical.
- Check behaviour, not just structure. Look-alikes, "obvious" moves and bug fixes all deserve a second look.
- Test what you can't click through, and prove the tests can fail.
- Write down your exceptions. A documented shortcut is a decision; an undocumented one is debt.
The codebase isn't perfect. But it now has a shape that explains itself, and lint enforces that shape.#architecture #nextjs #softwareengineering #software #coding #development #engineering #inclusive #community
Retrofitting Clean Architecture onto a Next.js App Without Stopping the World