If you’ve spent any time in the software product team space, you’ve probably heard a lot of opinions about the ideal team size and structure. Small, two-pizza teams are most effective. Teams who deliver end-to-end features are best. Organizations build systems that mimic their own team structure (Conway’s Law). These principles mirror what I’ve experienced in my 20+ years working with software teams. At Atomic, we prefer to staff projects with small teams because we know they’re effective and create environments for people to thrive.
With some preparation and the right tools, teams can maintain their small size and effectiveness even as a product grows. Here, I’ll share how my teams have used tools to help fit system architecture to our teams’ desired working model. In turn, this enables us to work in small, high-agency teams.
Before I get into the tools, I want to spend a few minutes describing the concept of useful boundaries between parts of a software system. You can create boundaries in many ways: separate Git repositories, separate infrastructure, different subdirectories, NPM modules, files, functions, etc. But not all boundaries are useful. Applied haphazardly or excessively, boundaries get in the way.
The boundary’s upside should outweigh the friction its presence will inevitably cause.
The benefits I look for in a useful boundary are that it:
- Creates a constrained space, a bounded context so that a human doesn’t have to understand life, the universe, and everything to work effectively.
- Creates an opportunity to clearly designate ownership for a smaller piece of a whole system.
- Allows the team to tailor the solution to the specific problem at hand (domain representation, operational needs, scaling, tools, etc.).
- Constrains the duration of tasks like building the app, running tests, deployments, etc. to keep developers productive.
Now, let’s take a look at a few tools that can help teams create useful boundaries to align system architecture with team structure.
At this point, microservices are not a new idea. In a microservices architecture, the back-end services break down into specific areas of responsibility. For example, there may be a user service, a notification service, a storefront service, a stock status service, etc. With those boundaries and interfaces defined, it’s easier for different teams to own those different services. You can manage their deployments and scale independently. The test suites can be independent, the data storage can be independent and tailored to the needs of each service, and more.
Microservices help teams control the scale of ownership of back-end systems to match the ideal team size. The scale of a microservice should be enough that the team can work in the service codebase without constantly crossing boundaries. However, it should not be so large that the team needs to grow much beyond its ideal size to deal with spikes in feature development demand or support. The team should be able to explain the purpose of the service in a few sentences: an elevator pitch for the service, essentially.
For example, one of my teams built a storefront for purchasing image and audio assets. Our storefront service domain focused on the sales listings. That way, the project could avoid a fair amount of complexity contained in the separate asset management service (moderation status, revisions of assets, ownership, complex type information, etc.). That let us build the storefront experience more quickly and kept our team’s concerns focused on the storefront’s needs.
Code modules, like those published to NPM, pip, Maven, Nuget, etc., are another way to create useful boundaries in a system. Modules can help teams expand feature ownership to include a more complete vertical slice of functionality. Or, they can help teams share code horizontally across a platform.
For example, on one project, we built an in-app notifications microservice, and our team also published a React component library to an internal NPM registry. That made it simple for other applications to include our standard notifications UI. Consumers could choose to use the full UI or use just the hooks to get the data and build their own UI. It helped our team deliver the complete notifications experience.
We’ve also built modules to share across several teams, like a UI library that helps apply consistent visual design across several applications. In our model with a collection of small teams, each team shared ownership and maintenance of the module. Shared modules like this help distribute work more flexibly across teams, at the cost of needing to coordinate tasks.
The primary pain points from these modules happen when they get compiled into and deployed with the hosting applications. If we changed any part of our notifications UI or API client code, for example, we had to coordinate with several teams to deploy the updates.
Micro Frontends & Federated Modules
Micro frontend architecture extends the idea of microservices into the front-end parts of an application. The goal is empowering teams to manage an entire feature from UI to back-end.
Micro frontends can be organized into components that get composed into a page, like the notification system example earlier. Or, you can organize them as full pages that live alongside each other within a thin shell to provide a seamless, single-site experience despite being owned and built by different teams.
Teams can build micro frontend components using tools like NPM modules as I mentioned above. But, to avoid some of the pain points we experienced with the compiled-in NPM modules, we used Webpack Federated Modules to compose the front-end application at runtime instead of compile time. Webpack’s module federation isn’t just for front-end applications. It can be used on back-end systems as well.
Because the module composition happens at runtime, federated modules help teams manage their release cycle with reduced coordination and dependence on other teams. At least this is the case for releases API-compatible with the previous version. The run-time composition creates workflow challenges for TypeScript users because it still needs the module’s types at compile time. But, there are ways to make it work.
My team built a microfrontend for the asset marketplace I mentioned earlier. We published the set of pages and components needed to allow users to browse and purchase assets. A thin shell application loads our module plus a couple of others at runtime, then decides which to display based on URL routing information. Our teams need to coordinate APIs and message structures for communication between the microfrontends, but the boundaries are clear. Each team knows what they’re responsible for.
Monorepos help teams reduce the pain of complexity that comes with a separation into multiple components. Rather than having a separate repository for each component, monorepos encourage teams to version many components together in a single repository. This makes it easier to acquire a set of component versions that will work together.
With a monorepo in place, teams may find it easier to create new microservices and microfrontends without much overhead. That makes it easier to exercise control of team size.
So far, my teams have primarily used Yarn Workspaces to set up monorepos, and our repositories have contained relatively few components (10 at the most). We’ve used the pattern to keep the front-end, back-end, some shared code, infrastructure automation, etc., together in a single repository for a full-stack service. I’m interested in experimenting with larger monorepos, but I’ll need improved tools to keep build and test run times in check (e.g., Turbo’s Turborepo and Turbopack).
Putting Teams in Control
All these tools can help put teams in control of their size and structure. When they create useful boundaries early, teams can avoid building a monolithic architecture that leads to a monolithic team. With micro architecture patterns established on a team, it’s easier to support teams splitting, handing off ownership of a component, or recombining as the level of new feature investment rises and falls.