A Map of Architecture Styles: From Layered to Microservices, Which Style for What?
An architecture style is a set of constraints; with the axes of deployment-unit count and technical versus domain partitioning, layered, modular monolith, pipeline, microkernel, service-based, event-driven, space-based, SOA, microservices and Web-Queue-Worker fit on one map. Pipeline and AssemblyLoadContext-based microkernel examples, our own style table for Bookstore, and why Clean and Vertical Slice are code organization rather than styles.
Version note: The code in this post was built and run with C# 14 and .NET 10.
Guess First
In Bookstore's architecture discussion these six terms show up in the same sentences, as if they were alternatives to each other: Microservices, Clean Architecture, Event-driven architecture, Vertical Slice Architecture, Modular monolith, CQRS (Command Query Responsibility Segregation). "Should we use Clean Architecture or microservices?" gets asked in the meeting in all seriousness.
Which of these six are architecture styles and which are not? What do the ones you marked "not" describe? Make two lists on a piece of paper. And think about this: can the same system be a modular monolith and use Clean Architecture at the same time?
The answer is in the middle of the post. First let's pin down the word "style", because half of the problem comes from using it loosely.
A Style Is a Set of Constraints
The Architecture Styles page of the Azure Architecture Center defines a style as a family of architectures that share certain characteristics, and adds an important detail: a style places constraints on the design. It restricts which elements can appear and which relationships are allowed between them. When you follow those constraints, certain desirable properties emerge. The page's example is microservices: each service represents a single responsibility, services are independent of each other, and data is private to the service that owns it and is not shared. In return for those constraints you get independent deployment, fault isolation and frequent releases.
The practical consequence of this view: you choose a style by its constraints, not by its name. A team that says "we use microservices" but connects every service to one database has taken the style's name, dropped its constraint, and therefore lost its benefit. The distributed monolith in the previous post was exactly that.
Two axes are enough to place styles on a map. These are the two axes used by FoSA (Fundamentals of Software Architecture, Mark Richards and Neal Ford, 2nd edition, O'Reilly, 2025): the first is the number of deployment units, a single unit (monolithic) or several (distributed). The second is partitioning: whether code is split by technical roles (technical) or by business areas (domain). The placement on the map is our own reading; some styles (like microkernel) lean both ways.

Monolithic Styles: Layered, Modular Monolith and Pipeline
We saw Layered Architecture and the modular monolith in the previous two posts. Layered splits code by technical roles such as presentation, business and data, and requires a request to pass through the layers in order. The modular monolith keeps a single deployment unit and splits by business area at the top level. Both are candidate styles for Bookstore today and in the near future.
Pipeline (pipes and filters) is a style people talk about less but use every day. Its constraints: work is split into small filters that do not know each other; each filter takes its input from one pipe and hands its output to the next; data flows in one direction. Filters differ by role: a source that produces data, those that transform data, those that drop what does not meet a condition, and a final link that consumes the result. The | operator in the Unix shell is the best-known form of the idea. In .NET, LINQ (Language Integrated Query) chains, System.IO.Pipelines and producer/consumer lines built with System.Threading.Channels are the same style at different scales; the "Producer/Consumer with C# Channels: Backpressure, Bounded Channels and Pipelines" post in the C# Async series covers the async side.
In Bookstore the natural home for a pipeline is the nightly price file. The distributor sends a CSV (Comma-Separated Values) file every night; rows are parsed, validated, given a margin based on stock and normalized:
using System.Globalization;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
// Price file the distributor sends every night: isbn;title;listPrice;stock
string[] feed =
[
"978-0441013593;Dune;10.00;14",
"978-0141439518; Emma ;4.80;3",
"not-an-isbn;Broken Row;3.00;1",
"978-0261103344;The Hobbit;9.20;0",
"978-0141439549;Middlemarch;-1;5"
];
List<string> rejected = [];
// Pipes are IEnumerable<T>, filters are small functions that do not know each other
IEnumerable<PriceRow> pipeline = feed
.Pipe(Filters.Parse(rejected))
.Pipe(Filters.Validate(rejected))
.Pipe(Filters.ApplyMargin)
.Pipe(Filters.Normalize);
foreach (PriceRow row in pipeline)
Console.WriteLine($"OK {row.Isbn} {row.Title,-12} {row.Price,6:F2} EUR stock {row.Stock}");
foreach (string reason in rejected)
Console.WriteLine($"SKIP {reason}");
public sealed record PriceRow(string Isbn, string Title, decimal Price, int Stock);
public static class PipeExtensions
{
public static IEnumerable<TOut> Pipe<TIn, TOut>(this IEnumerable<TIn> source, Func<IEnumerable<TIn>, IEnumerable<TOut>> filter) =>
filter(source);
}
public static class Filters
{
public static Func<IEnumerable<string>, IEnumerable<PriceRow>> Parse(List<string> rejected) => lines => ParseCore(lines, rejected);
public static Func<IEnumerable<PriceRow>, IEnumerable<PriceRow>> Validate(List<string> rejected) => rows => ValidateCore(rows, rejected);
// Lower margin when out of stock, higher when stock is low
public static IEnumerable<PriceRow> ApplyMargin(IEnumerable<PriceRow> rows) =>
rows.Select(r => r with { Price = Math.Round(r.Price * (r.Stock switch { 0 => 1.10m, < 5 => 1.35m, _ => 1.25m }), 2) });
public static IEnumerable<PriceRow> Normalize(IEnumerable<PriceRow> rows) =>
rows.Select(r => r with { Isbn = r.Isbn.Replace("-", ""), Title = r.Title.Trim() });
private static IEnumerable<PriceRow> ParseCore(IEnumerable<string> lines, List<string> rejected)
{
foreach (string line in lines)
{
if (line.Split(';') is [var isbn, var title, var price, var stock]
&& decimal.TryParse(price, CultureInfo.InvariantCulture, out decimal listPrice)
&& int.TryParse(stock, out int count))
yield return new PriceRow(isbn, title, listPrice, count);
else
rejected.Add($"parse: {line}");
}
}
private static IEnumerable<PriceRow> ValidateCore(IEnumerable<PriceRow> rows, List<string> rejected)
{
foreach (PriceRow row in rows)
{
if (!row.Isbn.StartsWith("978")) rejected.Add($"isbn: {row.Isbn}");
else if (row.Price <= 0) rejected.Add($"price: {row.Isbn} {row.Price}");
else yield return row;
}
}
}
OK 9780441013593 Dune 12.50 EUR stock 14
OK 9780141439518 Emma 6.48 EUR stock 3
OK 9780261103344 The Hobbit 10.12 EUR stock 0
SKIP isbn: not-an-isbn
SKIP price: 978-0141439549 -1
The pipeline's strength is modifiability: a new rule ("drop titles from blacklisted publishers") goes in as a new filter without touching the existing ones, and each filter is tested on its own. Its weak side is the one-way flow: an interactive checkout flow whose state changes often does not fit this shape. There is also a deliberate shortcut in the example: rejected rows are written to a shared List<string>. In a pure pipeline, errors flow through a separate pipe as well; the shortcut is acceptable for a small import, but it is the first thing to break once filters start running in parallel. The code-level counterpart of the pipeline is covered in the "Code-Level Patterns Beyond GoF: Null Object, Specification, Result and Pipeline" post in the Design Patterns series (GoF: Gang of Four, the 1994 Design Patterns book by Gamma, Helm, Johnson and Vlissides).
Microkernel: A Core That Grows Through Plugins
In the microkernel (plugin) style the system has two parts: a minimal but stable core, and plugins that implement the contracts the core defines. The core does not know the plugins, only the contract; adding new behavior means dropping in a new plugin. The .NET world has common examples of this style: Roslyn analyzers and source generators plugged into the compiler, MSBuild task assemblies, and ASP.NET Core startup assemblies loaded from outside through IHostingStartup.
In Bookstore, shipping cost calculation fits this shape: carriers and their tariffs change, the rest of the Shipping module does not. The contract lives in its own assembly:
namespace Bookstore.Shipping.Contracts;
// The only agreement between the core and the plugins
public interface IShippingRateProvider
{
string Name { get; }
decimal Quote(Parcel parcel);
}
public sealed record Parcel(int BookCount, decimal WeightKg, string City);
Each plugin is in its own project and knows nothing about the core:
using Bookstore.Shipping.Contracts;
namespace Bookstore.Shipping.FlatRate;
public sealed class FlatRateProvider : IShippingRateProvider
{
public string Name => "flat-rate";
public decimal Quote(Parcel parcel) => parcel.BookCount <= 3 ? 4.90m : 7.90m;
}
using Bookstore.Shipping.Contracts;
namespace Bookstore.Shipping.WeightBased;
public sealed class WeightBasedProvider : IShippingRateProvider
{
public string Name => "weight-based";
public decimal Quote(Parcel parcel) => 2.50m + Math.Ceiling(parcel.WeightKg) * 1.20m;
}
Following the approach of the "Create a .NET Core application with plugins" guide on Microsoft Learn, the core loads each plugin into its own AssemblyLoadContext. AssemblyDependencyResolver looks at the plugin's .deps.json file and resolves its dependencies from the plugin's own folder, so two plugins can use different versions of the same library:
using System.Reflection;
using System.Runtime.Loader;
namespace Bookstore.Shipping.Host;
// Each plugin is loaded into its own load context with its own dependencies
internal sealed class PluginLoadContext(string pluginPath) : AssemblyLoadContext
{
private readonly AssemblyDependencyResolver _resolver = new(pluginPath);
protected override Assembly? Load(AssemblyName assemblyName) =>
_resolver.ResolveAssemblyToPath(assemblyName) is { } path ? LoadFromAssemblyPath(path) : null;
}
using System.Globalization;
using System.Reflection;
using Bookstore.Shipping.Contracts;
using Bookstore.Shipping.Host;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
string pluginRoot = Path.GetFullPath(args.Length > 0 ? args[0] : Path.Combine(AppContext.BaseDirectory, "plugins"));
List<IShippingRateProvider> providers = [];
foreach (string dll in Directory.GetFiles(pluginRoot, "Bookstore.Shipping.*.dll", SearchOption.AllDirectories).Order())
{
if (Path.GetFileName(dll) == "Bookstore.Shipping.Contracts.dll") continue;
PluginLoadContext context = new(dll);
Assembly assembly = context.LoadFromAssemblyPath(dll);
IEnumerable<Type> candidates = assembly.GetTypes().Where(t => t is { IsClass: true, IsAbstract: false }
&& typeof(IShippingRateProvider).IsAssignableFrom(t));
int found = 0;
foreach (Type type in candidates)
{
providers.Add((IShippingRateProvider)Activator.CreateInstance(type)!);
found++;
}
Console.WriteLine($"{Path.GetFileName(dll)}: {found} provider(s)");
}
Parcel parcel = new(BookCount: 4, WeightKg: 2.3m, City: "Izmir");
foreach (IShippingRateProvider provider in providers.OrderBy(p => p.Name))
Console.WriteLine($"{provider.Name,-13} {provider.Quote(parcel),6:F2}");
Bookstore.Shipping.FlatRate.dll: 1 provider(s)
Bookstore.Shipping.WeightBased.dll: 1 provider(s)
flat-rate 7.90
weight-based 6.10
The style's most typical trap hides in the plugin's project file. The plugin must reference the contract project like this:
<PropertyGroup>
<EnableDynamicLoading>true</EnableDynamicLoading>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Bookstore.Shipping.Contracts\Bookstore.Shipping.Contracts.csproj">
<Private>false</Private>
<ExcludeAssets>runtime</ExcludeAssets>
</ProjectReference>
</ItemGroup>
When we removed the Private and ExcludeAssets lines from the FlatRate plugin and ran the same host, the build passed without errors, but the result was this:
Bookstore.Shipping.FlatRate.dll: 0 provider(s)
Bookstore.Shipping.WeightBased.dll: 1 provider(s)
weight-based 6.10
A copy of Bookstore.Shipping.Contracts.dll landed in the plugin folder. The resolver loaded it into the plugin's load context; the plugin's class implements the IShippingRateProvider in that copy, while the host looks for the one in its own default context. To the runtime these are two different types with the same name. No error, no exception; the plugin silently disappears. In a microkernel, the contract assembly belongs to the core and must not be shipped with the plugin.
Two more warnings. Microsoft's guide states it plainly: untrusted code cannot be safely loaded into a trusted .NET process. AssemblyLoadContext gives version isolation, not security or fault isolation; a StackOverflowException in a plugin takes down the whole process. Second, keeping the core minimal is hard: every new plugin need pushes "one more parameter" into the contract, and every contract change affects all plugins.
Distributed Styles: From Service-Based to Microservices
The styles in the upper half of the map use several deployment units and pay the network cost from the previous post. Which one you choose depends on what you get in return for that cost.
Service-based architecture is the gentlest step after the monolith. The system is split into a few coarse-grained domain services (FoSA puts the typical number between 4 and 12), services are deployed separately but often share a single database. For Bookstore it could be an intermediate state of three or four services such as Catalog, Ordering and Fulfillment. Independent deployment is partly gained; since the database is still shared, schema changes still need coordination.
In event-driven architecture components do not call each other; they publish events and react to events. In the broker topology there is no central coordinator and events are chained through a broker; in the mediator topology a mediator manages the steps. The gain is scalability, responsiveness and fault isolation; the cost is eventual consistency, message ordering, duplicate messages and harder debugging. It is the subject of the upcoming Messaging and Event-Driven Architecture series.
Space-based architecture is designed for very high, spiky load where the database is the bottleneck. Data is kept in a replicated data grid in the memory of processing units and written to the database asynchronously in the background. It provides elasticity for sudden peaks like ticket sales or flash sales; in return its complexity and cost are high and data consistency is hard. Bookstore's campaign peak (about 120 orders per minute) is far below the level that would need it.
Orchestration-driven SOA (Service-Oriented Architecture) is the enterprise approach of the 2000s: services are separated by a technical taxonomy and a central orchestration engine or ESB (Enterprise Service Bus) manages the flow between them. The goal was reuse; the result was often systems where everything was tied to the central product and change slowed down. Today it is mostly a historical reference, worth reading to understand why microservices say "copy rather than share".
Microservices are small services that can be deployed independently, each owning one business capability and its data. They give the highest independence and scalability and demand the highest operational cost: service discovery, distributed tracing, distributed data consistency, a mature CI (Continuous Integration) and CD (Continuous Delivery) pipeline. The upcoming Microservices series covers that cost in detail.
The Azure Architecture Center list has one more style that has no chapter of its own in FoSA: Web-Queue-Worker. A web front end, a message queue and a worker that does heavy work in the background. The front end and the worker scale separately. It is a good start for applications with a relatively simple domain but some heavy jobs; Bookstore's decision in the fourth post to put the post-order email on a queue is a small, in-process form of this style.
A Style Table Through Bookstore's Eyes
The table below is our own assessment and deliberately context-dependent: it was filled in with Bookstore's current state (3 developers, weekly deploys, about 800 orders a day, 10 times the traffic during campaigns) and its goals for the next 18 months in mind. More ★ means the style is better for Bookstore in that column. Another team under another load would fill the same table differently, and should.
| Style | Simplicity and operating cost | Fit for business changes | Independent deployment | Scaling | Fault isolation | Where it goes deeper |
|---|---|---|---|---|---|---|
| Layered | ★★★★★ | ★★ | ★ | ★★ | ★ | Post 05 of this series; Clean, Onion and Hexagonal Architecture |
| Modular monolith | ★★★★ | ★★★★ | ★ | ★★ | ★ | Modular Monolith series |
| Pipeline | ★★★★ | ★★★ | ★ | ★★ | ★★ | C# Async series, Channels post |
| Microkernel | ★★★ | ★★★ | ★★★ | ★ | ★ | This post |
| Web-Queue-Worker | ★★★★ | ★★★ | ★★ | ★★★★ | ★★★ | System Design series, asynchronous processing |
| Service-based | ★★★ | ★★★★ | ★★★ | ★★★ | ★★★ | Modular Monolith series, extracting a module into a service |
| Event-driven | ★★ | ★★★ | ★★★★ | ★★★★★ | ★★★★ | Messaging and Event-Driven Architecture series |
| Space-based | ★ | ★★ | ★★★ | ★★★★★ | ★★★ | System Design series, caching and the data tier |
| Orchestration-driven SOA | ★ | ★ | ★★ | ★★★ | ★★ | Historical reference |
| Microservices | ★ | ★★★★★ | ★★★★★ | ★★★★★ | ★★★★ | Microservices series |
Keep two things in mind when reading the table. First, the "Fit for business changes" column: since most of Bookstore's requests come from the business side, domain-partitioned styles stand out there; in a system dominated by technical changes, layered would score higher. Microkernel's three stars also only hold for plugin-shaped changes; adding a shipping tariff is easy, changing checkout is not. Second, the "Simplicity and operating cost" column: for a team of 3 this column outweighs the others, because monitoring ten services with a single ops person puts the availability target at risk all on its own. All the stars microservices collect in the other columns stay on paper if there is no team to pay for the single star in this one.
For Bookstore the reading is this: layered today, a modular monolith in the short term, with Web-Queue-Worker-like background processing for heavy work. In 18 months, with four teams (the subject of the eighth post), service-based or selective microservices come to the table. The green arrow on the map shows that path.
Not a Style: Where Clean, Hexagonal and Vertical Slice Sit
Back to the opening list. Microservices, event-driven architecture and the modular monolith are architecture styles: they constrain deployment units and the communication between them. Clean Architecture, Onion, Hexagonal (Ports and Adapters) and Vertical Slice describe how code is organized inside one deployment unit: which way dependencies flow, where use cases live, whether code is grouped by layer or by feature. CQRS is a pattern: it separates read and write models and can be applied inside any style. DDD (Domain-Driven Design) is not a style either, but an approach to modeling the domain and finding boundaries; bounded contexts are used both to draw module boundaries in a modular monolith and service boundaries in microservices.
That is why "Clean Architecture or microservices?" is the wrong question. Each module of a modular monolith can be written with Clean Architecture or Vertical Slice inside; a microservice can be layered inside. The two decisions are made at different levels and answer different questions. The style decides how the parts of the system are deployed and how they talk; code organization decides where a change lands inside one part.
These code-organization approaches have their own series in the category: the Clean, Onion and Hexagonal Architecture series and the Vertical Slice Architecture series. The CQRS and Event Sourcing and Domain-Driven Design series are in the same category. The Introduction to Architecture series only introduces the styles; the "how" of each lives in its own series.
Common Pitfalls
Choosing a style by its name. A decision like "let's go event-driven" is not a decision but a slogan if it does not say which constraints you accept and which characteristic you get in return. Start the choice from the characteristics list in the second and third posts.
Looking for a single style. Real systems are hybrids: Bookstore's main application is a modular monolith, the nightly price import is a pipeline, shipping calculation is a microkernel, email sending is Web-Queue-Worker. Each part can take the style that fits its problem; what matters is that the boundaries are clear.
Tearing the table out of its context. Copying the stars from a book, or from this post, onto your own system is the same mistake as detaching quality attributes from your own domain. Refill the table with your own team size, load and kinds of change.
Treating a plugin as a security boundary. AssemblyLoadContext gives version isolation inside the same process; a malicious or buggy plugin can take the process down or read its memory. Untrusted code needs a separate process or container.
Mistaking code organization for a style. Arranging folders by a Clean Architecture template does not change deployment, scaling or fault isolation; a monolith with Clean Architecture is still a monolith.
A Small Experiment
★☆☆ Place the system you work on on the map: how many deployment units does it have, is the code split technically or by domain? Then find a pipeline and a plugin point inside the system; almost every system has both.
★★☆ Add a filter to the price pipeline that rejects rows with an empty title. Then remove the rejected list and write a version where every filter returns a Result-like type and errors flow through the pipe too. Which filters' signatures changed?
★★★ Add a third plugin to the microkernel example and give it a dependency on a NuGet package (for example to read a tariff file in JSON (JavaScript Object Notation)). Build and load the plugin without EnableDynamicLoading, then turn it on and try again. See the difference in the .deps.json file and the plugin folder.
★★★ Fill in the style table for your own system. I expect you to reach a different result from this post in at least one column; if you cannot, you have not described your context concretely enough.
In this post we read the styles through their technical constraints. But what decides the green arrow on the map is not technical: when Bookstore moves to service-based will be decided not by the code but by the number of teams and how they talk. In the next post we look at Conway's Law and the team types of Team Topologies, and measure how differently the same commit history translates into coordination cost under different team structures.