Duplo Grows Beyond the Core Team

After the Stabilization phase wrapped up, Slack’s Duplo project moved into a combined modularization and modernization effort. As detailed in the initial post, the original plan was to run those as two separate phases, but the team soon realized that sequencing them would mean refactoring much of the code twice. Android work in particular was more about modularizing and adopting newer technologies than a full re-architecture, so a distinct modernization phase didn't fit. The phases merged into a single effort beginning in February 2021.

Splitting the work across the broader organization was essential. The smaller Duplo core team that handled Stabilization couldn't scale to touch every part of the codebase on its own. Each product pillar selected a lead responsible for planning that team's Duplo work, including deciding which features to prioritize and how to balance Duplo efforts against regular feature work. This distributed approach kept feature teams in ownership of their code while ensuring engineers got hands-on experience with the new design patterns the project introduced.

A central group of Duplo leads continued to coordinate the project, help pillar teams set goals, and offer guidance. Weekly syncs and dedicated Slack channels kept communication flowing. Infrastructure teams on both platforms handled their own code and supplied additional resources to update other areas of the codebase.

Modularization: The Framework

In Slack’s mobile codebases, the term “module” refers to a subproject, typically a static or dynamic framework linked into the app. Prior to Duplo, most code lived in the main app target with only some infrastructure code split out. During the project, modularization became a central pillar of the effort.

Moving code out of the monolithic app target delivered several concrete benefits:

  • Reduced build times in local and CI environments. On iOS, simply moving a file into a module could shave seconds off compile time without any other changes.
  • Smaller rebuild scopes, so developers could build and test within smaller targets as changes were made.
  • Cleaner architecture, with clear separations of responsibility and disentangled dependencies.
  • Greater developer independence, allowing work to progress in parallel.

Modularization also clarified code ownership. Previously, substantial code was “unowned” or shared across product teams. Setting the requirement that every module have a clear owning team helped reduce that unowned code and let Slack track migration progress by lines of responsibility.

Defining Module Types on iOS

Beyond structural changes, the iOS team imposed stricter definitions on what a module should be. Three types were defined: Features, Services, and Libraries.

  • Feature modules contain presentation code for a screen or a series of screens. Each feature splits into an Interface module (protocols and concrete data classes) and an Implementation module (implementations and internal helpers). Features may link against other Features’ Interface modules but never their Implementations, preventing coupling and ensuring implementation changes don’t ripple through dependency graphs.
  • Service modules handle functions like API calls, persistence, and common components. They generally don’t contain UI and are likewise split into Interface and Implementation pairs, with implementation modules off-limits for linking by other Service and Feature modules.
  • Library modules hold concrete data structures, simple classes, extensions, and utility functions. They sit at the bottom of the dependency graph and typically only depend on other Libraries.

Bazel adoption

A pivotal piece of the iOS effort was adopting Bazel. Before, Xcode managed builds locally and in CI, with the project file checked into GitHub and edited by hand. Adding a framework was complex, and project edits frequently caused painful merge conflicts.

With Bazel, build files defined targets, modules, and dependencies. Developers could generate XcodeGen definitions from the Bazel graph or build entirely with Bazel. Creating new modules became straightforward, and merge conflicts in project files nearly vanished. The team also shifted framework linking from dynamic to static libraries, producing a significant improvement in pre-main time during app launches.

Bazel’s shared cache is only effective when code is broken into discrete modules — the more the codebase fragments into smaller units with a clean dependency graph, the more effective caching becomes. The team plans a follow-up post covering Bazel adoption in more depth.

Graph of modules created
Growth of modules created before and after Bazel

The rise of code generation

Code generation wasn’t an original Duplo goal for iOS but became an important outcome. It improved developer experience and consistency while cutting boilerplate. Bazel made it straightforward to modify build targets programmatically and to add files by simply creating them in the right place. Key use cases included:

  • Generating new modules with scripts and file templates. A developer specifies a name and type, and the script creates the module’s Bazel definitions and starter files.
  • Updating CoreData models. Editing a Swift file and running a script generates all the read/write boilerplate for new properties or models.
  • Managing feature flags with scripts. Flags previously caused frequent merge conflicts; generated flags enforce consistency and eliminate collisions, making adding and removing flags trivial.

More extensive code generation adoption is planned as Bazel integration completes.

Android Modularization

Android follows a similar structure with Features, Services, and Libraries, but there are differences in execution.

Features

Android doesn't prescribe separate Interface and Implementation projects for every module — doing so would double the number of subprojects Gradle must evaluate during configuration. However, API/implementation project pairs proved useful for breaking circular dependencies during modularization.

Services and Libraries

Services implement business logic spanning more than one feature and generally avoid UI code. Libraries are reserved for highly reusable pieces like I/O or database utilities and internal telemetry, code that doesn't necessarily include Slack logic itself.

The Dagger strategy shift

Slack uses Dagger for dependency injection. An early plan broke the app’s dependency graph into smaller graphs per subproject module, each declaring its own Dagger Components that accepted external dependencies. At the app level, these components functioned as factories for types and providers.

That approach proved too complex. The boilerplate deterred all but Dagger experts, KAPT ran for nearly every project and made compilation slower, and multibound collections spanning projects were hard to handle. The solution was Square’s Anvil, which extends Dagger and helps break up component definitions that otherwise balloon to enormous sizes.

With Anvil, any subproject module can contribute a Dagger module, binding, multibound member, or component interface to the top-level component simply by declaring a scope. That significantly cut boilerplate code and saved compile time through Kotlin compiler plugin tricks. Slack has published more details on its use of Anvil.

Gradle investments

On the build side, Slack stuck with Gradle and focused on tooling and performance improvements. Internal custom tooling made onboarding new Gradle modules easier, measured modularization progress, and enforced the dependency rules between projects.

Configuration time grew as modules proliferated, a well-known Gradle pain point. The team enabled the then-incubating configuration caching after testing and troubleshooting it; it currently saves 20–45 seconds on successive builds after configuration changes. Gradle Enterprise served as both the distributed build cache and a tool for identifying delays, bottlenecks, and caching issues.

The Android team also reduced KAPT reliance by migrating Java-based libraries to Kotlin-based successors using Kotlin Symbol Processing (KSP). AutoValue and AutoValueGson gave way to Kotlin data classes with Moshi and Moshi’s IR adapter (experimental). Anvil eliminated a KAPT step for Dagger module contributions entirely.

Why Modularization Became the Centerpiece

When the Duplo project began, modularization was already flagged as a likely win. By the end, it proved to be the single most consequential piece of the work. Every other goal—accelerating mobile development and untangling the codebases—depended on getting the module boundaries right.

The results came from the same set of levers on both platforms: clear module categories, strict dependency enforcement, and heavy investment in build infrastructure. Adopting Bazel on iOS and deepening Gradle work on Android were not side quests. They were what made the architectural rules practical to maintain at scale.

Code generation also pulled its weight. Automating boilerplate and cross-module wiring removed friction that would otherwise tempt developers to bypass the new structure. The combination of enforced rules and generated code meant the architecture stayed consistent without requiring constant manual vigilance.

Where the Work Continues

Build tooling and code generation remain the two areas with the most headroom. Slack plans to keep pushing on both fronts, expecting the returns to grow as the tooling matures and more code paths are covered by generation.

Modularization was deliberately treated as the foundation of the broader Duplo effort, not an isolated cleanup task. A follow-up post covers the modernization phase of the initiative, including overall outcomes and what comes next.