Radically Simple Web Architecture — A Web Application Blueprint for Startups and Small Enterprises
A Starting Point for Management and Tech Leads
TL;DR: How to build web application across multiple domain teams using Modular Monoliths, SSR, Micro Frontends, HTMX and Tailwind CSS. (Code + Demo)
About this Article
This article offers a blueprint for small to mid-sized companies running or building a web application across multiple teams. The goal here is not to describe a theoretical tech architecture based strictly on “by-the-book” patterns, nor to create a high-level management guide on how to structure your IT organization. Instead, the aim is to provide a practical, experience-based guide that demonstrates how to get your HTML from point A to B with a simple, robust architecture, and the team structure to make it happen.
In software development, there’s a popular quote: “Premature optimization is the root of all evil.” Yet, I often find myself in the middle of projects that are complex, with numerous moving parts, but only minimal core business logic. This prompts the question — why? How does displaying some data from a database on a website become such a complex task?
The problem is what I call the “mental default.” Often, when a project begins, the most common architectural patterns and frameworks are applied without much questioning — simply because they’re widely used. It’s a bit like IBM computers in the 70s; they were the safe bet if you needed to purchase office equipment. That mindset was captured in the quote, “Nobody ever got fired for buying IBM.”
Today, you could say, “Nobody ever got fired for building Microservices and SPAs”, even though this is often an overly complex solution to a straightforward problem.
There’s an article I recommend called “Radical Simplicity” that encourages a return to simpler solutions by using fewer frameworks, libraries, and tools. This reduces overall complexity, freeing up development time to focus on creating business value.
In this blueprint, we’ll extend and apply the “Radical Simplicity” principle to web application implementation and architecture across multiple domain teams, providing a pathway from a straightforward to a more complex architecture as your needs evolve. This approach allows your architecture to grow progressively alongside your business — a concept we call “Radically Simple Architecture”.
Organizational Blueprint
Before diving into the technical solution, let’s cover the organizational structure — how to set up your teams and identify the right people for each role.
Imagine that you want to build a highly customized web shop or a SaaS application, requiring two small development teams to complete the workload. The first question that arises is how to divide the work between these two teams.
One option is to distribute work by technical layers (horizontally), such as back-end and frontend — a common pattern in the past. However, this setup leads to inter-team dependencies for almost every feature, with the knowledge for the same domain spread across both teams. This structure also doesn’t scale well if you need to add more teams in the future.
A better approach, in terms of independence, development speed, and concentrated knowledge, is grouping by business domain (vertically). In this approach, only one team would need to understand the new feature fully and would be able to handle the complete feature development, including both back-end and frontend work. Domain Driven Design (DDD) is a well known methodology of identifying and working with domains.
In a web shop, for instance, the domains could include: navigation, search, product detail, checkout, and account. Identifying domains and their boundaries isn’t always simple, so for this example, let’s assume we have a reasonable setup. At the beginning of a project, it may be helpful to think about potential domains in detail, then group them. For example, in a web shop, you might group navigation, search, and product into a “discovery” domain, leaving checkout as a separate domain.
For a SaaS application, there might only be two domains: account and scheduling, assuming it’s a team scheduling service.
You can always further subdivide domains, software, and teams as the business and its requirements evolve. However, an extensive discovery of potential domains is crucial before grouping them.
In addition to domain-specific logic, you’ll also encounter cross-cutting concerns, such as infrastructure and integration. Both are necessary to “glue” the “domain team” together at a technical level. We’ll cover the technical aspects later, but at a high level, these parts can be viewed as “supporting teams” that assist the business-oriented domain teams.
The diagram above illustrates the setup of two “domain teams” (discovery, checkout) and two “supporting teams” (integration, platform).
Domain Teams
Domain teams are responsible for designing, implementing, and operating features for the end user. These features are built in a self-contained manner from the front end down to the databases and servers hosting the application, known as end-to-end or vertical features.
For instance, a domain team handling the “account” domain would build the complete sign-up page, including rendering the page, input validation, tracking, and other frontend tasks. They’d also handle the back-end work, such as storing user data and triggering emails.
Depending on the size of your overall product and the number of business domains, you may have multiple teams working independently to complete the end-to-end tasks.
Ideally, each team should be small and long-lasting, allowing it to focus on work rather than processes and internal alignment. A team of 4 full-stack developers and one product manager/owner works well. The product manager gathers feature requirements and channels them to the team, while also handling scope, time, and budget considerations.
Having full-stack developers on a team is advantageous for distributing work internally since no one is limited to just one area, like backend or frontend. While it might seem that a full-stack developer can handle any task, the reality is that they often still have a preference or area of expertise. This makes it important to have a balanced mix of frontend and backend skills on the team.
UI/UX is essential for a complete domain feature, so teams usually share a single UI/UX designer who provides initial designs and supports development by working closely with the developers.
Supporting Teams
Supporting teams do not directly implement end-user features. Instead, they provide the services and infrastructure domain teams need to function effectively.
In this example, we’d have one platform team enabling domain teams to deploy and run their applications in the cloud by providing an empty could account or maybe just a PaaS (e.g., Heroku) account, depending on your requirements. The platform account will be also providing solutions for monitoring and log collection.
At the other end of the stack, we’d need a integration team to “glue” domain teams’ work into a cohesive site, making it appear as a single website to the user, even if different teams contribute independent pages. This team would handle DNS setup, routing, and page assembly, as well as creating a pattern library used by domain teams for consistent UI/UX. The implementation of the pages is not part of the integration team.
Supporting teams typically provide “guardrails” and services that the domain teams can utilize and integrate with as needed.
In larger organizations, these supporting teams could consist of dedicated developers working solely on these system components.
In smaller setups like our example, the supporting teams can be virtual teams formed by representatives (one or two) from each domain team. They’d meet regularly to discuss tasks related to their supporting area, with actual work then completed within the domain teams.
Typically, supporting work is concentrated in the project’s initial phase. Once these supporting pieces are in place, feature development takes over as the primary focus for domain teams.
Technical Blueprint
With our organizational setup and team roles defined, let’s explore what technologies we could use in detail and how the overall architecture will look like.
The final architecture will meet the following requirements:
- The website will consist of individual HTML pages contributed by teams (e.g., product page, account page).
- Smaller HTML fragments, also provided by the teams, can be included by other teams (e.g., header and footer).
- Pages and fragments should merge seamlessly, creating a unified experience for the user.
- Teams must be able to work, deploy, and scale independently.
- The architecture must be able to support a growing organization.
- The “Simplicity” principle must be applied: When there are two options, we opt for the simpler one until the complexity of the situation necessitates the more advanced option.
To meet these technical and organizational needs, we’ll use a combination of Self-Contained Systems, Server-Side Rendering, and Micro Frontends as the core of our architecture.
We will start from the perspective of the user accessing the application, then dive in the implementation of the domain teams, and build toward the complete big picture.
Integration Layer
The integration layer is a set of tools and resources that combine pages provided by domain teams into a cohesive application. We’ll use a pattern called Micro Frontends which helps modularize the application while creating a unified experience for the end user.
The integration team will set up these tools and resources but won’t build the pages itself. That is the done by the domain teams.
To understand the structure of the front end, we’ll follow a user’s request from the browser down to the application.
Related: Short deep dive into Micro Frontends with HTMX and NGINX
Routing
Our website will be hosted under a single top-level domain, like example.com
. When the user enters that domain, the browser sends a request to our system and expects an HTML response. Since multiple teams serve different pages, we route requests based on the URL. For example:
/
→ discovery team/product/id
→ discovery team/account
→ checkout team/cart
→ checkout team
Each route then returns an HTML page that’s rendered by the browser.
The same applies to HTML fragments. A fragment is a small markup piece included within a page rather than a full HTML page.
/navigation/fragment/v1/header
→ discover team/account/fragment/v1/user-icon
→ checkout team
Adding versions to fragment URLs is considered best practice. This approach is essential if there are breaking changes in your fragments, such as a completely redesigned layout. The including page can then choose which version to load.
Page Assembly
Using Micro-Frontends requires a way to merge the HTML pages and fragments from different teams into one cohesive HTML response. There are various methods to include and replace fragments, depending on the tech stack and frontend framework you’re using.
For this example, we’ll use Server-Side Rendering and avoid JavaScript frontend frameworks, focusing instead on plain HTML requests for fast and efficient rendering.
Returning to our example: a call to example.com/cart
might return a complete HTML page from the checkout team. The cart page may also include a shared header and footer hosted by the discovery team.
In the HTML structure, we’ll use virtual includes for header and footer elements:
<html>
<head>
...
</head>
<body>
<!--#include virtual="/navigation/fragment/v1/header" -->
<div>
Cart Page Content...
</div>
<!--#include virtual="/navigation/fragment/v1/footer" -->
</body>
</html>
Our page assembly solution will merge the HTML of pages and fragments into one final HTML page.
NGINX, a web server, is an ideal tool for this job, with built-in support for replacing includes with HTML from specified URLs. Additionally, it can handle URL-to-team routing, making it the entry point for every web request.
A full request cycle would proceed as follows:
- The browser requests
example.com/cart
. - NGINX calls
/cart
on the checkout domain. - The checkout domain returns HTML with
include
statements. - NGINX parses the HTML for includes.
- NGINX calls the two
/navigation/fragment
endpoints on the discovery domain. - The discovery domain returns the header and footer HTML.
- NGINX replaces the includes with the fragment HTML.
- The browser receives the complete HTML from NGINX and renders the page.
Patten Library
So far, we’ve covered how the HTML returned by the domain teams is merged into a fully valid HTML page. In the assembled HTML code, you won’t see team boundaries anymore. This same level of integration must also be achieved in the layout. Every page needs to feel like a cohesive, unified experience.
Pattern libraries vary widely in technical depth. At the simpler end of the spectrum, a library might be a PDF style guide or a Figma file showing buttons, forms, colors, etc., which each domain team would translate into its own HTML and CSS. At the more technical end, a pattern library could be a code library included in each team’s code, with components that save development time but limit the team’s choice of frontend stack.
A middle ground includes CSS frameworks with reusable code components, reducing team workload while leaving frontend stack choices flexible by using plain HTML, CSS, and minimal JavaScript.
For instance, the Bootstrap framework was a game changer, enabling developers to create attractive UIs without building them from scratch. Bootstrap was easy to use, though somewhat limited in customization. Consequently, more flexible CSS utility frameworks like Tailwind CSS have emerged, keeping the ease of copy-paste code while allowing more flexibility in component design.
<button class="bg-gray-300 hover:bg-gray-400 text-gray-800 font-bold py-2 px-4 rounded inline-flex items-center">
<svg class="fill-current w-4 h-4 mr-2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M13 8V2H7v6H2l8 8 8-8h-5zM0 18h20v2H0v-2z"/></svg>
<span>Download</span>
</button>
The code above shows a download button build with Tailwind CSS. You can copy & paste that code in any frontend stack, include the tailwind.css
and it will always look that same—that is a very powerful feature.
The pattern library we’ll use here is exactly that: a component library built from Tailwind components and templates. Think of it as a highly customized Bootstrap specifically for this project. The pattern library will offer:
- A hosted webpage listing the copy-paste code components. This library enables teams to browse and find components. It can be a simple static website or a solution like Storybook.
- A hosted
myproject-tailwind.css
file, including all utility classes for the components. Before diving into performance optimizations, you can use the public CDN for the basictailwind.css
. - A hosted
alpine.js
file, which powers components that require some interactivity, like an accordion or flyout modal, using AlpineJS. This can also be loaded from a public CDN. - A hosted
htmx.js
file or CDN version of HTMX. It gives the teams access to AJAX, CSS Transitions, WebSockets and Server Sent Events directly in HTML, using attributes, so they can build modern user interfaces with the simplicity and power of hypertext.
Each page will include the Tailwind CSS, HTMX and AlpineJS sources, allowing each domain team to assume these resources are available for rendering their server-side HTML pages and fragments.
Domain Layer
The domain layer of our architecture will contain multiple applications handling the business logic for the domains and serving the pages that are merged by the integration layer.
Self-Contained System
For the application, we want to follow the self-contained system architecture. There is a highly informative article on the details and advantages of SCS, but to simplify, you can think of SCS as a small, vertical slice of your larger web application that runs independently and contains the user interface, business logic, and persistence for a specific domain like “discovery.”
For each domain that a team owns, it will create an SCS. As mentioned in our example, we want to group multiple domains within the discovery team. For this team, we would have an SCS for navigation, search, and product.
At this point, we’re not yet discussing the code organization itself or the deployment units we’ll have later on. We want to focus on achieving independence for each domain at every level of the technical stack.
Code Structure & Deployment Units
As mentioned earlier, independence is a key characteristic of our micro-architecture. As such, you might think putting each SCS into its own microservice and deployable unit is an ideal solution — it’s become the default approach in recent years.
Microservices provide clear separation of concerns and enforce strict boundaries between domains. However, this setup makes changes more challenging due to its distributed nature, requiring versioning and migrations. This may not be the best solution when starting a new project.
A Modular Monolith also enforces strict boundaries but offers an implementation that is more adaptable to changes because it has only one codebase and one deployable unit. Keeping the “cost of change” low is especially important for new projects where domain boundaries may shift as the business grows and requirements evolve.
For our example, this means moving each domain and its SCS into a separate module. Most programming languages and build tools support modules that isolate one codebase from another.
With our general code structure in place, we now move on to the layers of the Self-Contained Services.
UI Rendering
Following our principle of simplicity, we will also take this approach in the rendering layer of the stack by opting for “Server-Side Rendering First” (SSR).
Today, the Single Page Application (SPA) approach is commonly the default choice. But unless you’re building a complex UI that resembles a desktop application, like Miro, Gmail etc. an SSR-powered Multi-Page Application is usually a better starting point. If your use cases include mostly rendering static data from the backend, providing input forms, displaying modals, and similar common web functionalities, SSR is the right choice. To replicate the fast, dynamic feel of SPAs, tools like HTMX can be used to make your MPA feel like an SPA.
A primary reason to begin with SSR is to simplify the tech stack and enhance development speed — think of the principles outlined in “Radical Simplicity.” SSR requires less code to maintain, update, and understand.
Thus, each of our SCSs will return HTML that is rendered on the service using a templating engine. To reduce complexity further, we’ll share the same frontend libraries and CSS frameworks across all UIs. These will be provided by a CDN, which eliminates the need for additional frontend build steps in our SCS builds.
HTMX, combined with AlpineJS and Tailwind CSS, should cover most of the functionality and layout requirements. If an SCS still requires additional JavaScript or custom CSS, it can add its own files as needed.
Business Logic
The business logic is the core of your domain. This is where the actual logic for your features is implemented — where data from the UI is processed, validated, stored, or retrieved from persistence.
For this layer of the stack, choose a programming language and web framework that works well for your team and organization. Just make sure it integrates smoothly with SSR and includes essential web capabilities like routing, form handling/validation, templating, and authentication, if required. Within each module, you are free to choose the micro-architecture that best fits, whether that’s Hexagonal Architecture, a 3-Layer Architecture, or any other pattern to maintain a clean separation between UI, Business Logic, and Persistence.
Persistence & Data Exchange
To maintain independence, each SCS must manage the data it requires to render its UI and perform domain-specific actions.
For example, in a search SCS, this would mean handling the rendering of the search input, storing products within its own search index to execute searches, and rendering the search results page. However, since the product domain (and its corresponding SCS) is the primary source of all product data, this data must be replicated to the search domain.
For replication, we have three main options:
- Data Fetch: The search SCS performs a synchronous fetch to a JSON endpoint in the Product SCS, loading only the specific products needed to render search results.
- Data Feed: The search SCS periodically pulls the full product dataset from the Product SCS via a JSON endpoint, using an asynchronous job to store it in its own database. This can happen as a full or incremental update. When rendering search results, the product data is then loaded directly from this local database.
- Data Events: The Product SCS pushes product data onto an event bus, which is consumed by the Search SCS and stored in its own database. Reading all
ProductUpdateEvents
from the start would allow a full update. When rendering search results, the product data is retrieved from the local database.
In all cases, it’s important to establish a standardized pattern and conventions for implementation to ensure the same mechanism is used consistently across all data exchanges (e.g., product, user) between domains.
Starting with a Data Fetch is often a good idea if this call is between two SCSs within the same modular monolith, as the API call will occur in-memory, bypassing the network.
If the SCSs are in separate deployments, true data replication is needed for performance reasons. In this scenario, Data Feeds are often an effective approach, as they offer a robust and relatively easy-to-implement solution.
Adjusting Architecture
We decided to use one Modular Monolith per feature team, containing multiple domains. Each domain is a code module implemented as a Self-Contained Service, resulting in a very compact and flexible configuration for the start of our project.
Typically, traffic to your web application will grow over time. As an initial response, vertical scaling (more CPU and memory) and horizontal scaling (adding more instances) should address increased demand.
But what happens as domains and features grow, the codebase becomes larger, some SCSs experience higher web traffic than others, or if you need an SPA for a particular domain?
At this point in the project — and only then — we start splitting our monolith and using the boundaries and layers we previously established.
Horizontal Split
A horizontal split might be necessary if one domain (SCS) requires its own deployable unit.
This may occur if one domain experiences significantly higher web traffic, or if the codebase and use cases grow so much that the domain will need to be managed by a newly created team.
A Modular Monolith is essentially a scaled-down version of a larger architecture, where logical boundaries and communication patterns stay consistent. Transitioning one module into a standalone application will replace in-memory communication with its physical counterparts.
- Module → Own Application
- Service Call → HTTP API Call
- In Memory Event Bus → Hosted Event Bus
- 1 Deployable → n Deployable
Vertical Split
A vertical split occurs when you need to separate one layer of an SCS within a domain.
A common reason for this is the need to separate the user interface from the backend (business logic and persistence), switching from an SSR approach to an SPA and API setup as the user interface complexity increases.
The API would still remain in the monolith module, but the SPA would be a separate application and deployable. In this case, endpoints that previously returned server-side rendered HTML would return JSON instead.
- UI Layer → Own UI Application
- Service Call → HTTP API Call
- HTML Route → API Route
- 1 Deployable → 1 + SPA Deployable
Platform Layer
The platform layer allows teams to deploy, operate, and monitor their applications.
Application Hosting
Again, there is wide range of technical solutions each with its own pros and cons when it comes to hosting an application.
There’s a wide array of options for hosting applications, each with its own pros and cons. Simple options like AWS AppRunner, Google Cloud Run, or Heroku let you run a Docker container and link a domain in a matter of hours.
Alternatively, you could start with an empty cloud account, giving teams complete freedom but requiring a greater initial setup.
Whichever solution you choose, it’s essential that each team can work in isolation.
Given that we’re working with a small setup in terms of organization, it’s advisable to start with a simple solution, then scale up as requirements outgrow the basic setup.
Monitoring & Logging
Another essential function the Platform layer provides is a logging and monitoring solution. Domain teams should not need to set up their own solutions.
Tools like Datadog are ideal for this task, enabling teams to send events and application logs, which Datadog then stores and indexes. Teams can build custom dashboards, set up alerts, or query logs as needed.
Big Picture
Finally, let’s take a look at the full picture, covering both the organizational structure and the technical design along with its implementation.
The “Radically Simple Architecture” model is a flexible approach to building and scaling web applications for startups and small to mid-sized companies. It evolves incrementally, allowing adaptation as the business grows, and avoids premature optimization and overengineering by focusing on simplicity and adding complexity only when needed.
Ultimately, it balances simplicity and scalability, ensuring technical decisions support business growth and deliver value efficiently.
About the Author
Alex is a founder, architect and full stack developer at Inaudi Tech. He has worked the last 20+ years in we application development and helped small start-ups as well as big e-commerce companies to find the right tech stack for their missions
Feel free reach out for radical help or consulting at https://www.inauditech.com/
Code and Demo
Related Content
Just the title image… so next time, build something that gets you almost fired.