When Tech Debt Outgrows the Team
By early 2020, Slack’s mobile teams were feeling the weight of years of organic growth. The iOS codebase had not seen a major architectural reinvestment since its creation in 2013, and the Android app — rewritten in 2015 — was similarly showing its age. When workspaces could be counted in the hundreds, clients fetched nearly all team data at session start and APIs pushed events broadly. But Slack now serves organizations with 500,000 users or 20,000 channels. That scale, combined with a feature set that has expanded to include app integrations, shared channels, and enterprise admin tools, rendered many early assumptions untenable.
The desktop client and the PHP backend had both been through major reinvestments — the Sonic rewrite of the desktop client and the PHP-to-Hack conversion. The mobile codebases had not received the same treatment. Over time, development had slowed enough to affect product roadmaps. The mobile teams knew that incremental fixes would no longer be enough; meaningful architectural improvements could only come from a coordinated, large-scale refactoring effort.
The Cost of Inconsistency
On both platforms, the bulk of the code lived in large, monolithic app targets. This meant that any change triggered time-consuming rebuilds, and there was no clear boundary between components. Interdependencies were tangled, classes tried to do too much, and global state and singletons made it easy for seemingly unrelated changes to break each other. As the mobile teams grew, these patterns made independent development difficult, and as the codebase grew, build times became a bottleneck.

Stalled migrations added another layer of inertia. On iOS, the team was mid-way through adopting infrastructure libraries and pushing toward 100% Swift from Objective-C. On Android, the team had moved to Kotlin but was still tied to AutoValue and AutoValueGson for JSON parsing, keeping areas of the code in Java when they preferred Kotlin data classes and Moshi. None of these migrations were close to completion without dedicated effort.
The architectural picture was equally unclear. iOS development was nominally based on an MVVM+C (Model, View, View Model + Coordinator) pattern, but it was not universally adopted and lacked strong guidance on how to assign class responsibilities — meaning each feature was structured slightly differently. On Android, an MVP (Model, View, Presenter) architecture had been used for years, yet it had not been fully applied across the codebase. These inconsistencies complicated onboarding, created uncertainty about how to write new code, and ultimately made mobile feature development take longer than it should have.
Why Not Rewrite or Share Code?
Once the decision to act was made, the teams considered a full rewrite. The appeal of starting fresh is obvious, but the risk was too high: if a rewrite hit major blockers, the mobile teams could waste enormous time and resources. A new codebase, meanwhile, would be developed in parallel with a legacy one, requiring every feature to be implemented twice while the rewrite was underway, plus reimplementation of years of legacy functionality. Developers also believed the existing structure was fundamentally sound, particularly on Android, which argued against treating the codebase as a lost cause.
Sharing code across platforms, either through cross-platform UI frameworks or shared infrastructure, was also on the table. Slack had tried shared code in the past and hit drawbacks: performance issues, a loss of native look and feel, and complications with debugging and tooling. Slack’s mobile engineers favored native languages and platform APIs, and aimed for best-of-breed apps on each platform. The choice, therefore, was a refactor of the existing codebases with a strong emphasis on redesigning patterns and architecture — without a complete rewrite.
Project Duplo
Launched in the summer of 2020, Project Duplo was a coordinated effort across the iOS and Android teams. Its goal was consolidated: reduce tech debt, speed up development, adopt better patterns and technologies, and prepare the codebases for the next five years of Slack engineering. The project was led by individual contributors from the mobile teams, who studied the options, wrote proposals, and presented detailed plans to engineering leadership and executives.
The proposal addressed three main areas. First, it outlined how the initiative would directly tackle the issues slowing development, backed by data on build times, test failure rates, migration progress, and mobile architecture at peer companies. Second, it defined how progress would be measured, using annotated dashboards that tracked targets like build time reductions, migration milestones, and lines of code in the main app target. Third, it anticipated likely failure modes at each phase, including organizational risks around coordination and momentum across multiple contributing teams, as well as technical risks. The intent was to give leadership a clear understanding of expected returns on a substantial, long-term investment.
Despite differences in details, the iOS and Android plans shared the same three-part framework:
- Stabilization: clearing out the worst tech debt and anti-patterns while finishing critical stalled migrations.
- Modularization: breaking apart the monolith into smaller, independent building blocks (hence the name Duplo) to reduce interdependencies, lower build times, and enable smaller teams to work more autonomously.
- Modernization: adopting forward-leaning technologies and design patterns to future-proof the codebases and align with industry trends.
Project planning across both platforms was coordinated by a small group of leads from each of the iOS and Android teams. Work on the Stabilization phase began first, giving the teams time to lay concrete, detailed plans for the more complex phases of Modularization and Modernization that lay ahead.
Shipping the stabilization phase
Project Duplo’s first phase, Stabilization, ran for six months with the goal of stopping what Slack’s mobile team described as “the bleeding” — paying down the tech debt that was slowing feature work on both iOS and Android. A dedicated core team of developers, plus platform leads, worked exclusively on the effort so that progress wasn’t diluted by feature requests.
On each platform the team started by interviewing engineers about friction points and using code health metrics to identify the worst files. From there, both platforms set distinct goals for tackling the biggest structural issues in their codebases.
iOS: Swift, data access, networking
iOS had three primary Stabilization goals:
- Port remaining Objective-C code to Swift
- Finish migrating to internal data access libraries
- Finish migrating to the native networking library
Secondary goals included broader adoption of UI components and reducing the use of singletons for navigation.
At the outset, roughly 80,000 lines of Objective-C remained — about 15% of the codebase. Keeping the two languages in one app forced developers to maintain Objective-C interfaces for interoperability, which blocked modern Swift patterns, added marshaling overhead at language boundaries, slowed runtime performance, and lengthened build times. A fully Swift codebase would let the team adopt new language features more freely.
Similarly, several layers of data access libraries had been built on top of CoreData, but legacy code still called CoreData directly in many places. Those direct calls were a recurring source of bugs, data inconsistencies, crashes, and threading problems. Full adoption of the data provider frameworks would remove that class of defects and make it possible to change caching behavior without touching feature code.
The native networking library — built for API calls, HTTP requests, and WebSocket connections — was only partially adopted. Some code paths still relied on a third-party library, leading to inconsistency across features and making it harder to add cross-cutting capabilities like analytics or request priorities.
Android: breaking up monoliths
Android’s five Stabilization goals were:
- Split the monolithic networked API interface by feature area
- Migrate remaining database queries to SQLDelight
- Split the monolithic database access interface into data access objects (DAOs)
- Standardize on the repository pattern for network/disk data access
- Remove remaining usages of Otto bus events
The Android client’s network surface had grown dramatically alongside the product. Every endpoint the app called was defined in a single SlackApi interface, which tightly coupled features that would later need to be separated into modules. Splitting that interface by feature area was a prerequisite for the modularization phase that followed.
SQLDelight, Square’s library for typed SQLite access, was already in use in parts of the app. The team wanted to complete the migration because it validates schemas, queries, and migrations at compile time, reduces boilerplate, and supports query caching. It also offered a clean path for multiple sub-projects to contribute schemas to a shared database — an important property for future modular work.
The database access layer had the same monolith problem as SlackApi: a single PersistentStore interface provided operations for nearly every Slack data type, from messages and channels to bots and commands. Breaking it into type-specific DAOs aligned with newer architectural patterns and unblocked modularization.
The team also adopted the repository pattern as a standard way to abstract local versus server data. Previously there was no consistent approach for deciding when to fetch or mutate data from persistence versus the network, which made it harder to implement smarter caching.
Finally, the Android app’s 2015 rewrite predated broad RxJava usage, so real-time server events were propagated through an event bus from Otto, another Square library. Most of the app had moved on to RxJava, but stray usages remained — and since Otto had been deprecated for years, it was time to remove it completely.
Measuring progress
Jira task tracking helped with burndowns on individual items, but it didn’t provide a good gauge for how large migrations were progressing. Instead, the team relied on automated scripts that searched the codebase for deprecated patterns. Dashboards built from those scripts showed whether the work was on track for each goal’s deadline — and, if not, where resources might need to be reallocated.

On iOS, the scripts could simply count remaining Objective-C files for the Swift conversion effort, and search for deprecated method and class names to track the status of other migrations.
Stabilization complete
By early 2021, the Stabilization phase had met all of its deadlines on both platforms. Removing Objective-C entirely let iOS engineers use modern Swift features and later made it easier to adopt frameworks like Combine. Full adoption of the internal data frameworks eliminated a category of crashes and bugs from improper CoreData access, and enabled caching-layer changes without feature-level updates. Complete adoption of the native networking library meant new features like analytics and request priorities could be added once and used across the product — there was now one correct way to do things instead of several.
On Android, the stabilization work produced a more consistent codebase, easier onboarding, and the ability for teams to work in parallel when extracting focused components into their own sub-projects. The phase also validated that Duplo’s overall approach could deliver real impact, which laid the groundwork for the more ambitious phases that followed: modularizing the codebases, modernizing patterns and technologies, and — on iOS — a substantial overhaul of the app and feature architecture.



