A Console That Cannot Drift: Letting Modules Declare Their Own Screens
Why our operator console has no database, no domain code, and no knowledge of any module until it asks.
Every admin console eventually drifts, because it is the one place where knowledge about a module lives outside that module.
Ours was a second application: its own server, its own idea of what a user or a pet or a device looked like, its own copy of every table name. When the backend changed a field, someone on another team updated the console a week later — or didn't, and an operator discovered it by clicking. Every module we added to the platform was also a screen somebody had to write somewhere else, against an API they didn't own.
The replacement inverts the ownership. A module declares its screens in the same package where it declares its data, its events and its settings. A thin shell signs you in, asks the backend what exists, and loads it. Adding, renaming or removing a screen is a backend change and a backend deploy; the shell never changes for it.
The Short Version
The console is not an application that knows about modules. It is a loader that asks.
One rule governs the design:
A module owns its data, its management routes and its screen; the shell knows no module by name.
Everything below is a consequence. A module declares a management surface in a conventional file; the backend discovers those declarations at boot and serves a single filtered registry document; the browser loads each module's bundle as a native ES module, pinned by hash, into one shared React runtime. No micro-frontend framework, no shell rebuild, no drift.

available to the signed-in user, the shell loads them, and the browser runs the screen in a shared runtime.
Start with ownership, not navigation
The first design decision was to put a module's management declaration beside the module itself.
That declaration describes the screens and actions the module exposes. A module that has nothing ready for operators can say so explicitly. The absence of a declaration should not silently look like a valid, empty module.
This is a small convention with a useful consequence: the console no longer needs to be updated just because a module is added, renamed, or removed. The module's owner changes its declaration and implementation; the shell continues to consume the same contract.
It also gives the team a place to validate consistency. If a screen refers to an action that the module never declared, we want to find that during startup or testing — not after an operator opens the screen.
The principle is simple, but it changes the direction of dependency. The shell depends on a description of available screens. It does not import each module to learn what it does.
Discovery belongs outside the core
We learned this by building a prototype, not by starting with a perfect diagram.
Our first attempt put module discovery too close to the shared types. Discovery needs to inspect module declarations; the core types should not need to import the modules that use them. Combining those responsibilities undermined the boundary we were trying to create.
We moved discovery to the layer that can see the modules and kept the shared contracts independent. The backend now gathers declarations, validates them, and builds a registry for the signed-in person.
That registry is the shell's source of truth for navigation and module loading. It contains the screens and actions the person may see, along with the information needed to load the corresponding client bundle.
There is an important distinction here: filtering the registry is not authorization. Hiding a button or omitting a screen makes the interface clearer, but it does not secure an operation. The backend must make the access decision again when a request arrives. The interface helps people navigate; the server remains responsible for enforcing access.
This separation also gives us a useful test: the registry and the backend's action guard should agree about which actions a person can perform. If the UI advertises something the server will always refuse — or the server accepts something the UI believes is unavailable — we have a contract problem.
Keep the shell generic
The shell has a deliberately narrow job: manage sign-in and session state, request the registry, build navigation, load a module, and provide the shared runtime and lifecycle.
It should not contain business logic for a module. Nor should it need a growing list of module names to decide which screen to render.
The browser side uses native ES modules and an import map. Each module screen is built as a bundle that refers to the shared runtime instead of carrying its own independent copy of React. The shell resolves the bundle from the registry and mounts it into a supplied element.
A module exposes a small lifecycle: mount, update, and unmount. That contract matters because the shell must be able to change the screen's context without needlessly recreating it, and remove a screen without leaving its listeners, requests, or DOM behind.
The shared-runtime constraint is enforced during the build. We do not want every module author to have to remember a rule that the platform can check for them.

module resources, and mounts the requested screen after the browser's integrity checks.
Integrity has to cover what the browser uses
The prototype also exposed a release-versioning mistake.
We initially treated JavaScript as the main unit of a screen's release. But a screen can change without its JavaScript changing. A stylesheet-only release can alter the appearance or behavior of a page that is already open, while a JavaScript-only identifier remains the same.
The rule we took forward is to version the client-facing resources together when the screen depends on them together. The registry carries integrity metadata for the relevant runtime and module resources, and the browser checks resources covered by that metadata before using them.
This is a specific guarantee, not a claim that integrity metadata solves every supply-chain or deployment problem. The metadata must come from a trusted source, the relevant files must be covered, and the publishing process must keep the registry and assets consistent.
The practical lesson is broader than hashing: version the thing the client actually depends on, not just the easiest file to identify.
The prototype's other lesson: do not build a framework to solve a boundary problem
One of our early attempts to re-export a dependency did not produce the named exports the runtime needed. The dependency's CommonJS output did not behave like the ES module surface we expected.
Rather than introduce another abstraction, we moved the compatibility work into the build and generated small, explicit shims for the packages the runtime consumes. That kept the runtime's contract clear and made the output easier to inspect.
This is the kind of problem a prototype is good at finding. A design can look clean on a whiteboard while a real bundler exposes assumptions about module formats, exports, styles, or lifecycle behavior. It is cheaper to learn those lessons before the implementation has accumulated dependants.

hidden assumptions into concrete design rules.
Why not use a micro-frontend framework?
We considered the decision in terms of the problem, not the popularity of the options.
| Option | What it would mean for this console |
|---|---|
| A federation framework | Useful for more independent runtime composition, but adds another model and coordination surface to operate. |
| Iframes | Provide a stronger isolation boundary, but make shared navigation, session context, and UI conventions more involved. |
| Shell-owned screen packages | Familiar, but keep the shell coupled to module UI packages and their release cycle. |
| Native ES modules and an import map | Use browser primitives directly, with more responsibility on our build contract, resource integrity, and lifecycle discipline. |
For the scope we were solving, native modules and an import map were sufficient. That is not a universal recommendation. If modules need stronger isolation or independently deployed applications with more complex runtime composition, another approach may be the better fit.
The important point is that we chose the smallest mechanism that addressed the coupling we had actually observed.
What this means for day-to-day engineering
The value of the design is less about the number of files or components and more about the ownership rules it makes explicit.
- A module declares its own management surface. Its screens and actions live beside the code that owns the domain.
- The backend discovers and filters. The registry reflects the signed-in person's available surfaces, while request-time checks remain authoritative.
- The shell stays generic. Navigation and loading are driven by the registry, not a hard-coded list of modules.
- The build enforces runtime constraints. Shared dependencies, bundle output, and resource metadata are checked rather than left to memory.
- The lifecycle is a contract. Screens can be mounted, updated, and removed without the shell knowing their implementation details.
These boundaries are also useful in review. When a change adds a screen, we can ask whether the module declaration, access rules, route contract, bundle, and lifecycle behavior agree. That is a more actionable review than asking whether the new screen appears in the console.
What we are taking forward
The prototype did not prove that every operational concern was solved, and it was never the goal to publish the prototype as a finished platform. It helped us identify which constraints the production implementation needs to preserve.
That distinction matters. A working proof of concept can validate a design direction, but it is not a substitute for the remaining integration, security, and browser-level tests. We should be explicit about what has been demonstrated and what still needs enforcement.
The durable outcome is the set of rules: module ownership stays with the module, discovery stays outside the core, the shell consumes a registry, the backend remains the authority for access, and the build pins the resources the browser actually uses.
A prototype is finished when the team can explain what it taught them — and turn those lessons into rules that the next implementation cannot casually ignore.
That is the part worth keeping.
Hoomanely builds connected pet-care products — a smart feeding bowl and the EverWiz app that pet parents use to track feeding, health and community. Behind the app is a team of operators who answer a pet parent's question by looking at the same data the app shows them. When an operator console drifts from the platform it reports on, the cost isn't an internal inconvenience; it's an answer given from a stale picture of someone's pet. Putting module ownership where it belongs is how we keep the console honest to the product — and it is the same standard we apply to our device telemetry and our data pipelines: the system that reads the data must stay accountable to the system that owns it.