Visibility Is an Architecture Problem
How I redesigned a browser SDK by drawing lines that didn't exist before.
A few months after I joined a software team, I was asked to make a small change to our browser SDK, an NPM package used as the bridge between the UI (React) and Web Assembly Build (provided by our core team, built in C++).
It took me three days.
Not because the change was complex. I simply couldn't find where the change was supposed to live. The codebase had long files, mixed responsibilities, lots of duplicated logic, and no clear signal of where anything belonged (no clear separation of concern, et al.).
Internal helpers sat next to public-facing methods. Types bled across files. It was a tangled web; move one strand, and the whole structure shakes.
That's when I stopped trying to make the change and started asking a different question: what would this look like if it were designed to change?
The problem with "just works"
The original SDK worked. It did its job. But "works" is a low bar when you're the one maintaining it six months later.
The thing that bothered me most wasn't the duplication or the long files; those are symptoms of an unhealthy organism. The real problem was visibility. Everything was exposed. Every internal helper, every type, every utility function was part of the public surface. If you installed this package, you were handed a map of the entire engine room and told to find the ignition yourself.
That goes against the principles of abstraction and information hiding that Ousterhout points out in his book "The Philosophy of Software Design":
"If a piece of information is hidden, there are no dependencies on that information outside the module containing the information, so a design change related to that information will affect only the one module".
Hiding information reduces the blast radius of change. It also reduces the cognitive load for the developers adopting the SDK. They shouldn't need to understand its internals to use it. A good library should feel like a set of explicit, opinionated methods; you call them, you get a result, you move on. The implementation is none of your business.
So I proposed a new architecture. Not a full rewrite, more like drawing lines that didn't exist before.
Four layers and one direction
The architecture I landed on has four layers. Data flows in one direction: top to bottom. Dependencies point inward. Each layer has exactly one reason to exist.
Here's the shape of it:
Presentation → Application → Modules → Worker
Let me walk through each one.
Layer 1: Presentation
This is the only thing consumers of the SDK ever see.
It's also the simplest layer in the codebase — just functions. Not classes, not objects you need to instantiate. Functions. You call them, they return something, you're done.
// presentation/index.ts
export function cameraOpen() {
return cameraService.open();
};
export function documentPhotoIdScan() {
return documentService.photoIdScan();
};
export function documentPassportScan() {
return documentService.passportScan();
};
That's the whole layer. A function that calls a method on an instance and returns the result. No error handling here, no business logic, no orchestration.
The point is simple: to control what's visible. This layer is a deliberate constraint on the public API surface. If it's not exported here, it doesn't exist to the outside world.
There's also a practical reason for functions over classes. JavaScript developers don't expect to instantiate a library. You import a function, you call it. That's the idiom. The presentation layer respects that.
Layer 2: Application
This is where the SDK comes together. The application layer instantiates all the module classes and wires their dependencies. If module A needs an instance of module B to work, that relationship is declared here and nowhere else.
// application/index.ts
const permissions = new PermissionsService();
const camera = new CameraService(permissions);
const verification = new DocumentService(camera);
I deliberately avoided a dependency injection container here. We were already making a lot of changes at once: new architecture, code improvements, migrating the worker layer (more on that in a moment).
DI framework abstracts the dependencies through the modules. In many cases, as it is in NestJS, you see the dependencies being defined in the class through @Injectable notation. Although that handles the complexity of managing multiple dependencies, our case was simpler and I did want to have an explicit way of mapping the dependencies in a single place and that place is the application layer.
Adding a DI framework on top of that would have been one more concept to learn and one more thing to debug. More importantly: we didn't feel the pain that a DI container solves. That's the signal. Don't reach for a tool before the problem it solves is real.
We kept it simple. Plain constructor injection, explicit wiring, no magic.
Layer 3: Modules
This is where the work happens. Modules come in three types:
Core modules own the business logic. In an identity verification SDK, that's the stuff that matters — the verification flow, the state machine, the rules about what's valid and what isn't. If the SDK were a company, this is the product.
Support modules handle things that aren't core value but are required for core value to work. Camera and Permissions are good examples. Accessing the camera requires a browser permission. Managing that permission isn't our core product, but without it, nothing works. Support modules exist in service of core modules.
Internal modules are never exposed. Feature flags live here. Shared utilities live here. And critically, this is the only layer that talks to the layer below it. If something needs to reach the worker, it goes through an internal module. That boundary is intentional.
Layer 4: Worker
At the bottom of the stack, there's a single Web Worker.
Its job is narrow: communicate with our one external dependency provided as WebAssembly build from another team. That WASM build does the heavy lifting: face recognition, document scanning, and cross-referencing the two.
Since it handles computationally heavy processing that we don't want blocking the main thread, we decided to have a Web Worker dealing with this communication. That way it can run in isolation and returns results through callbacks.
When I arrived, this layer was a JavaScript file. It worked, until it didn't.
We started hitting silly runtime errors we couldn't catch until they happened in production. Most of them related to TypeErrors such as trying to access something that was undefined.
So I rewrote the worker as a TypeScript class, consistent with everything else in the new architecture. The instance of this worker class is exposed using Comlink avoiding the complexity of transferring objects between threads.
The upgrade from JavaScript to TypeScript wasn't about preference. It was about making a class of errors impossible at runtime by catching them at compile time, in a layer that communicates with an opaque external dependency, that matters.
The part about logs
One thing I didn't expect to value as much as I do: every log in the SDK is tagged with its layer.
[CameraService] open called
[DocumentService] scanPassport start
[Worker] scanPassportWASM error: { message: 'Error' }
When something breaks, and things break, I know exactly where to look. Before this architecture, debugging meant scanning through files hoping for a clue. Now it means reading the layer tag and opening one file.
That's not a feature. That's a property of good layering. When responsibilities are clear, failures are locatable.
You don't need to see the future
I didn't design this architecture by predicting every feature we'd need to build. I didn't know what new modules were coming, what the worker layer would eventually look like, or how complex the verification flow would get.
What I did was draw clear lines in the present.
Lines about what's visible and what isn't. Lines about where dependencies are declared. Lines about which layer is allowed to talk to which.
Those lines didn't predict the future; they made the future easier to navigate. That's the difference between code that scales and code that just works. Not clever abstractions or over-engineered patterns. Just clear, honest boundaries that tell the next engineer (maybe future you) exactly where things belong.