The Declarative Skeleton That Cut Code by ~40%
The Declarative Skeleton That Cut Code by ~40%
Imperative codebases scale linearly with team size, at best. Every new engineer adds code, and that code has to understand and correctly interact with everything that came before it. More engineers means more implicit conventions, and every convention is one more thing to violate.
I've spent my first year at Dropbox watching this play out. Smart engineers, writing reasonable code, producing unreasonable bugs. Not because anyone is careless, but because the codebase allows too many ways to express the same intent. Better code review and more documentation don't fix that. A different kind of system does.
Apple validated this thinking at WWDC 2019 with SwiftUI, a fully declarative UI framework. React's influence is unmistakable. Flutter hit 1.0 last December with the same declarative philosophy. The industry is clearly converging on this idea. But we can't wait for SwiftUI to mature (it requires iOS 13 and is missing critical features). We need the declarative benefits now, on UIKit. The result so far: roughly 30-40% reduction in code and approaching 90% test coverage on the framework layer.
Imperative vs Declarative
Tyler McGinnis has a great analogy for this distinction:
Imperative: "I see that table over there is empty. My husband and I are going to walk over there and sit down."
Declarative: "Table for two, please."
In the imperative version, you describe every step. You have to know the layout of the restaurant, check which tables are available, navigate around other diners, pull out the chairs. You're responsible for the entire process, and if you get any step wrong (say, you sit at a reserved table) the system fails.
In the declarative version, you state what you want and the host handles the how. The host knows things you don't: which tables are reserved, which section is being served, where to seat you for optimal kitchen throughput. You get a better outcome by knowing less. In code, that's the difference between a codebase where every feature is a bespoke implementation and one where features are configurations of a well-tested framework.
The Imperative Tax
In an imperative iOS codebase, building a new screen looks something like this:
- Create a view controller
- Set up a table view or collection view with data source methods
- Manually manage cell registration, dequeuing, and configuration
- Write diffing logic or call
reloadDataand hope for the best - Handle loading states, empty states, and error states
- Wire up navigation for each interactive element
- Write the same pull-to-refresh boilerplate for the 47th time
Every screen, every engineer, every time. And each implementation is slightly different: different animation timing, different error handling, different approaches to state management. Imperative code has no mechanism to enforce consistency, so the inconsistency is structural, not intentional.
The cost isn't just the initial implementation. Every bug fix has to be applied to every screen that reimplemented the same logic. Every design system change requires touching dozens of files. Every new engineer has to reverse-engineer the implicit patterns before they can be productive.
The Declarative Alternative
What if, instead of writing all of that, an engineer wrote this:
struct SettingsSection: ListSection {
let cells: [ListCell] = [
TextCell(text: "Account", action: .navigate(.account)),
ToggleCell(text: "Dark Mode", binding: \.isDarkMode),
TextCell(text: "Storage", detail: "2.4 GB used"),
TextCell(text: "Sign Out", style: .destructive, action: .signOut)
]
let header = "Settings"
}
No view controller setup. No data source methods. No cell registration. No diffing logic. No navigation wiring. The engineer describes the screen, and the framework handles rendering, animations, state management, accessibility, and navigation.
This is what I'm building. An app skeleton so declarative that an entire screen (with pull-to-refresh, loading states, animated diffing, and deep linking) can be expressed as a data structure.
The Minerva framework on GitHub shows the list management piece of this approach: declarative cell models that the framework diffs and renders automatically. But the skeleton went further: navigation, dependency injection, analytics, error handling. All declarative, all flowing from the same philosophy.
What Declarative Constraints Actually Buy You
The immediate reaction from experienced engineers is usually skepticism. "You're limiting what I can do." Yes. Exactly. When the framework owns the rendering pipeline, individual engineers can't:
- Break animations by calling
reloadDatainstead of batch updates. The framework handles diffing. Every list in the app gets smooth, consistent animations for free. - Leak memory through common table view patterns. The framework manages cell lifecycle. No forgotten
prepareForReuseimplementations. - Skip accessibility. When cells are declarative data structures, the framework can enforce accessibility labels, traits, and actions systematically. It's built into the cell model protocol, not optional.
- Invent ad-hoc navigation. Navigation flows through the coordinator pattern, which is wired into the declarative skeleton. There's one way to navigate, and it handles deep linking, analytics, and back-stack management.
- Forget error handling. The skeleton provides standardized loading, error, and empty states. Engineers opt in to custom states; they can't accidentally omit them.
Each constraint eliminates a whole category of bugs. You don't fix "the settings screen forgot to handle the error state." You make it structurally impossible to forget.
What Happened When We Migrated
These constraints sound good in theory. In practice, the first few migrations were painful. Engineers pushed back on the loss of flexibility, edge cases exposed gaps in the framework, and I spent more time refining the API than I'd planned. But once the early surfaces landed, something shifted. Engineers stopped fighting the framework and started requesting migrations for their own features.
The codebase shrank. Migrated features came out around 30-40% smaller, not because we were compressing logic, but because the framework absorbed all the boilerplate that every screen used to reimplement. Less code means less surface area for bugs, less code to review, and fewer places for subtle inconsistencies to hide.
Testing got dramatically easier. The framework layer sits at coverage approaching 90%, and because every feature is built on that foundation, the hard parts (diffing, state management, navigation) are already tested. Feature-level tests can focus on the interesting question: is the data transformation correct, not does the table view animate properly.
The biggest surprise was onboarding. New engineers went from needing weeks to ship their first meaningful PR to needing days. When the answer to "how do I build a screen" is "declare a list of cell models," the learning curve flattens fast. Senior engineers shift their leverage too: instead of building one screen at a time, they improve the framework, and every screen built on it gets better automatically. A performance optimization to the diffing engine speeds up every list in the app. Update the base TextCell style, and every settings screen and detail view updates with no migration PRs touching 200 files.
The Hard Part
I won't pretend this is easy to build. A good declarative framework requires:
- Deep understanding of the problem domain. You need to know what engineers actually need to express before you can design the right abstractions. Build the framework too early and you constrain incorrectly. Build it too late and the imperative code is too entrenched to migrate.
- Willingness to iterate on the API. The first version will be wrong. Engineers will hit use cases you didn't anticipate. The framework needs to evolve without breaking existing consumers.
- Escape hatches. Declarative systems that can't handle edge cases breed resentment. I'm providing escape hatches for custom cells, custom transitions, and custom state management. The key is making the escape hatch explicit and slightly uncomfortable, so engineers reach for the declarative path by default.
- Champions who migrate the existing code. A framework nobody uses is a framework that gets deleted. Someone has to do the unglamorous work of migrating existing screens and proving the value.
This is the philosophical foundation that Rethinking VIPER pointed toward: architecture isn't about code organization, it's about what you make easy and what you make hard. Point-Free's Brandon Williams and Stephen Celis are exploring similar ideas with their Composable Architecture, building functional, testable state management for SwiftUI. We're solving the same problem for UIKit at production scale.
What Comes Next
The declarative skeleton is a foundation, not an end state. The next level of scale is breaking the monolith into independent modules that can be developed, tested, and compiled in isolation. That requires a different tool (dependency inversion, which I'll write about separately), but it's only possible because the declarative framework provides stable interfaces between modules.
Make the framework declarative and you're not just reducing bugs. You're building a system that gets better as the team grows, instead of worse. That's the only kind of architecture worth investing in.