ApiaryActive
Try: pause · settings · learn · wipe
← Community / Reading Room
AP
craft · 9 min read

Architectural Patterns for Large-Scale UI Codebases

The transition from a "working prototype" to a "large-scale production application" is rarely a linear progression of adding features; it is a fundamental…

The transition from a "working prototype" to a "large-scale production application" is rarely a linear progression of adding features; it is a fundamental shift in the nature of the challenges a team faces. In the early days of a project, the primary bottleneck is delivery speed. However, as a codebase grows to hundreds of thousands of lines of code, dozens of contributors, and hundreds of intersecting states, the bottleneck shifts to cognitive load. When a developer can no longer hold the mental model of the entire application in their head, the risk of regression increases, onboarding slows to a crawl, and the "velocity" of the team plateaus or drops.

At Apiary, we deal with a unique intersection of complex data visualizations for bee colony health and the orchestration of self-governing AI agents. Our UI cannot simply be a collection of pages; it must be a resilient ecosystem. If a change in the "Agent Memory" module inadvertently breaks the "Pollen Distribution" heatmap, the system has failed. Large-scale UI architecture is not about choosing the right framework—whether it is React, Vue, or Svelte—but about establishing the boundaries, communication protocols, and dependency rules that prevent a codebase from collapsing under its own weight.

The goal of a professional UI architecture is to decouple the what (business logic and domain rules) from the how (the specific UI framework or API implementation). By applying rigorous patterns like feature-based modularization and dependency inversion, we can build interfaces that are as adaptable as a biological colony: highly specialized in their individual roles, yet seamlessly integrated into a cohesive whole.

The Fallacy of the "Folder-by-Type" Structure

Most developers begin their journey with a "folder-by-type" organization. This is the classic structure where all components live in /components, all hooks in /hooks, all services in /services, and all types in /types. While this feels intuitive in a small project, it is a recipe for disaster at scale.

In a folder-by-type system, adding a single feature—for example, a "Hive Health Monitor"—requires the developer to jump between five or six top-level directories. This creates a high degree of "shotgun surgery," where a single logical change is scattered across the entire file tree. More dangerously, it encourages an implicit, tangled web of dependencies. When every component in the application is in the same folder, developers tend to import whatever they need from wherever it is, leading to a "big ball of mud" where everything depends on everything.

To combat this, we move toward Feature-Based Modularization. In this pattern, the codebase is divided by domain. A /features directory contains folders for hive-monitoring, agent-orchestration, and conservation-metrics. Each feature folder is a self-contained microcosm containing its own components, hooks, API logic, and state management. If the hive-monitoring feature needs to be deleted or moved to a separate micro-frontend, it can be done by deleting a single folder rather than hunting for fragments across the entire project.

Defining the Feature Module Boundary

A true feature module is more than just a folder; it is a boundary. To maintain the integrity of this boundary, we implement a strict "Public API" pattern using index files. Each feature folder should have an index.ts (or .js) file that explicitly exports only the components and functions intended for use outside that module.

For example, in the agent-orchestration module, there may be twenty internal helper components and five complex state-management hooks. However, the rest of the application only needs to know about the <AgentDashboard /> and the useAgentStatus() hook. By only exporting these two items via the index file, we create a contract. The internal implementation of the AgentDashboard can be completely rewritten—switching from a table view to a node-graph view—without requiring a single change in the pages that consume it.

This encapsulation reduces cognitive load. A developer working on the conservation-metrics module doesn't need to understand the internal state transitions of the agent-orchestration module; they only need to understand the public API. This mimics the way self-governing-ai-agents operate: each agent has a private internal logic and a public set of capabilities it offers to the swarm. When boundaries are clear, the system can scale horizontally without increasing the complexity of individual parts.

Shared Services and the Core Layer

While feature modules handle domain-specific logic, every application requires a foundation of shared utilities. This is where the core or shared layer comes in. The shared layer is reserved for "pure" logic and generic UI primitives that have zero knowledge of the business domain.

A common mistake is to put business-specific logic into the shared folder because "multiple features use it." For instance, if both the hive-monitoring and conservation-metrics modules need to format a "Bee Population" number, the formatting logic should not necessarily go into /shared. If the logic is specific to the domain of apiculture, it belongs in a domain-services layer or a shared beekeeping-utils module. The /shared directory should be reserved for things like date-formatter.ts, Button.tsx, or api-client.ts.

The rule of thumb is: The Shared layer can be imported by any feature, but the Shared layer cannot import from any feature. If a shared component starts importing from a feature module, you have created a circular dependency, which is the architectural equivalent of a colony collapse. To manage this, we utilize dependency-graph-analysis tools like Nx or Madge to visualize imports and automatically fail CI builds if a boundary is violated.

Dependency Inversion in the UI Layer

One of the greatest risks in large-scale UI development is the tight coupling between the UI and the data fetching layer. When a component calls axios.get('/api/v1/hives') directly inside a useEffect, that component is now coupled to the network protocol, the API endpoint structure, and the specific library used for fetching.

To solve this, we employ Dependency Inversion. Instead of the component depending on the API client, the component depends on an abstraction (an interface or a service).

In a professional architecture, we introduce a "Repository" or "Service" layer. The component interacts with a HiveRepository interface. At runtime, the application injects a HttpHiveRepository implementation. This provides three massive advantages:

  1. Testability: We can inject a MockHiveRepository during unit tests, allowing us to test UI states (loading, error, empty) without spinning up a real server.
  2. Adaptability: If the backend switches from a REST API to GraphQL or a WebSocket stream for real-time bee activity, we only change the code in the HttpHiveRepository class. The UI components remain untouched.
  3. Consistency: Centralizing data access allows us to implement global caching, retry logic, and error handling in one place rather than duplicating it across fifty components.

This separation of concerns ensures that the "View" is a pure reflection of state, while the "Service" layer handles the messy reality of asynchronous I/O.

Managing Global State vs. Local State

The "Global State Trap" is a common failure mode in large UI codebases. Teams often start with a global store (like Redux or Zustand) and treat it as a dumping ground for every piece of data. This leads to a monolithic state tree where a change in a small user-preference toggle triggers a re-render of the entire application.

To scale, we categorize state into four distinct levels of scope:

  1. Local State: State that lives and dies within a single component (e.g., isDropdownOpen). Use useState.
  2. Feature State: State shared across several components within one feature module (e.g., the current filters for the hive-monitoring list). Use a feature-level Context or a localized store.
  3. Global State: State that is truly universal (e.g., authenticated user, theme, language). Use a global store.
  4. Server State: Data that is a cache of what is on the server (e.g., the list of active bee colonies). Use a dedicated server-state library like TanStack Query or SWR.

The most critical distinction is separating Server State from UI State. Server state is asynchronous, can be out of date, and requires loading/error handling. UI state is synchronous and local. When these are mixed in a single global store, the complexity of the reducers and actions grows exponentially. By moving server state into a dedicated caching layer, we remove roughly 60-70% of the boilerplate code from the state management layer.

The Design System as a Technical Constraint

A design system is often viewed as a UI/UX concern, but in a large-scale codebase, it is a critical architectural constraint. Without a strictly enforced design system, "CSS drift" occurs. One developer creates a PrimaryButton with padding: 12px, another creates a SubmitButton with padding: 10px, and soon the codebase is littered with a dozen slightly different button implementations.

At Apiary, we treat the design system as a separate internal library. This library provides Atomic Components—the smallest possible building blocks (Atoms, Molecules, Organisms). These components are "dumb"; they do not know about the API, the global state, or the business domain. They only accept props and emit events.

By enforcing the use of these primitives, we ensure that the feature modules remain focused on logic rather than styling. When we need to update the brand colors across the entire platform to reflect a new conservation initiative, we change a single variable in the design system's theme provider, and the change ripples through every feature module automatically. This creates a "single source of truth" for the visual language, mirroring the way standardized-communication-protocols allow different AI agents to collaborate without ambiguity.

Orchestration and the Page Layer

With feature modules and shared services in place, we need a way to compose them. This is the role of the Page Layer. Pages are not where logic lives; they are "orchestrators."

A page's only responsibilities are:

  1. Routing: Determining which data to fetch based on the URL.
  2. Composition: Placing feature modules on the screen in a specific layout.
  3. Communication: Passing data between two feature modules that cannot depend on each other.

If Feature A needs to tell Feature B to refresh, they should not communicate directly. Instead, the Page (the parent) should listen for an event from Feature A and call a method on Feature B. This maintains the "unidirectional data flow" and prevents the "spaghetti" effect where features are tightly coupled.

For example, on the ColonyOverviewPage, we might have a HiveMap feature and a HiveDetails feature. When a user clicks a hive on the map, the HiveMap emits an onHiveSelect event. The Page catches this event and updates the selectedHiveId state, which is then passed as a prop into the HiveDetails feature. The map and the details panel remain completely ignorant of each other's existence, making them individually reusable and easier to test.

Why it Matters

Architecture is often dismissed as "over-engineering" in the early stages of a product. However, the cost of ignoring these patterns is not a one-time fee; it is a high-interest loan that the team pays back every single day in the form of slower development, more bugs, and developer burnout.

When we build with feature modules, dependency inversion, and clear state boundaries, we are doing more than just organizing files. We are building a system that can evolve. In the context of bee conservation, the data we track today will change as our understanding of pollinator behavior evolves. In the context of AI agents, the way these agents interact will shift as the models become more autonomous.

A rigid, tangled codebase is a liability that prevents a team from pivoting. A modular, decoupled architecture is a strategic asset. It allows a team to move fast not because they are rushing, but because they have built a foundation that makes the right way to do things the easiest way to do them. By treating our UI as a living ecosystem—balanced, bounded, and resilient—we ensure that our software can scale as ambitiously as the missions we are supporting.

Frequently asked
What is Architectural Patterns for Large-Scale UI Codebases about?
The transition from a "working prototype" to a "large-scale production application" is rarely a linear progression of adding features; it is a fundamental…
What should you know about the Fallacy of the "Folder-by-Type" Structure?
Most developers begin their journey with a "folder-by-type" organization. This is the classic structure where all components live in /components , all hooks in /hooks , all services in /services , and all types in /types . While this feels intuitive in a small project, it is a recipe for disaster at scale.
What should you know about defining the Feature Module Boundary?
A true feature module is more than just a folder; it is a boundary. To maintain the integrity of this boundary, we implement a strict "Public API" pattern using index files. Each feature folder should have an index.ts (or .js ) file that explicitly exports only the components and functions intended for use outside…
What should you know about shared Services and the Core Layer?
While feature modules handle domain-specific logic, every application requires a foundation of shared utilities. This is where the core or shared layer comes in. The shared layer is reserved for "pure" logic and generic UI primitives that have zero knowledge of the business domain.
What should you know about dependency Inversion in the UI Layer?
One of the greatest risks in large-scale UI development is the tight coupling between the UI and the data fetching layer. When a component calls axios.get('/api/v1/hives') directly inside a useEffect , that component is now coupled to the network protocol, the API endpoint structure, and the specific library used for…
References & sources
  1. Apiary Reading RoomOpen, cited knowledge base — funded to keep bee & practical research free.
From the Apiary Reading Room. Opinion & editorial — not financial advice. We don't overclaim.
More from the Reading Room