Context
Most modernisation programmes fail for the same boring reasons: the business wants change faster than the codebase can absorb it, the platform team is asked to preserve availability while replacing the engine, and the organisation treats “rewrite” as if it were a delivery strategy. It is not.
A monolith is not automatically a problem. Many monoliths are operationally simple, easy to reason about, and cheap to deploy. The problem appears when a monolith becomes the only unit of change, the only unit of ownership, and the only place where integration logic, domain logic, and data access all accumulate. At that point, every product change becomes a coordination problem.
The goal of modernisation is not to chase architectural fashion. The goal is to reduce the cost of change while protecting production stability. In practice, that usually means moving from a monolithic application to a modular platform in controlled slices, using a boundary-first strategy.
Microservices are often introduced into this conversation too early and too casually. They are not the goal. They are one possible outcome when the real problems are distribution and ownership. If a team needs separate release cadences, independent scaling, clearer blast-radius control, and explicit ownership boundaries, microservices may solve those distribution and ownership problems. If those problems are not present, splitting the system can simply move complexity into networking, observability, and data consistency.
The wrong question: “Should we rewrite it?”
The rewrite question sounds decisive, but it usually hides three separate decisions:
- Can the current system continue serving customers while we change it?
- Which parts of the system actually block delivery today?
- What boundary lets us extract value without freezing the business?
A rewrite answers none of these well. It replaces a live system with a future promise. The future promise tends to underestimate hidden behaviour, overestimate team bandwidth, and ignore the cost of data migration and operational parity.
A modular modernisation plan starts from the opposite direction. It assumes the existing system will remain live for a long time. The task is to create safe seams, isolate volatility, and move responsibility gradually.
A practical framework: strangler fig boundary
The strangler fig pattern is still useful because it is honest about incremental change. A new capability grows around the old one, one branch at a time, until the legacy core is no longer the system’s centre of gravity.
The key idea is not “replace everything slowly.” The key idea is “define a boundary where new behaviour can be routed without destabilising existing behaviour.”
What makes a good strangler boundary
A useful boundary has these properties:
- It is visible to the business, not just to engineers.
- It can be routed through a thin integration seam.
- It contains a coherent slice of capability, not arbitrary classes.
- It has measurable traffic or ownership so progress is real.
- It can fail independently without taking the whole platform down.
A good first boundary is often one of these:
- Customer onboarding
- Pricing rules
- Notification orchestration
- Search/read models
- Fulfilment workflow
- Reporting and exports
These are good candidates because they often have clear entry points, repeatable flows, and observable outcomes.
Boundary design rule
Do not draw the boundary around technical layers. Draw it around a business capability with a stable contract.
That means the new module should own:
- its own public API or message contract,
- its own internal model,
- its own decision logic,
- and preferably its own persistence.
If the boundary still depends on half the monolith’s tables, it is not a boundary; it is a dependency graph with better branding.
Integration seam trade-offs
Once the boundary is chosen, the next decision is the seam. There is no universal best option. The right seam depends on latency tolerance, coupling, change frequency, operational maturity, and the need for auditability.
1) Event or message bus seam
This is often the best choice when the capability is naturally asynchronous or when the system already emits business events.
Strengths
- Loose coupling between producer and consumer
- Good fit for eventual consistency
- Supports multiple downstream consumers
- Reduces direct runtime dependency on the legacy core
Costs
- Harder debugging across hops
- Ordering and duplication must be handled explicitly
- Requires mature observability and idempotency
- Event schemas become long-lived contracts
Best used when
- The business can tolerate eventual consistency
- The new module should react to facts, not control synchronous workflows
- Multiple systems need the same business event
2) API seam
An API seam is the most familiar option. The new component calls into the old one, or the routing layer forwards requests to the new component while the legacy system remains the source of truth for some flows.
Strengths
- Easy to understand
- Strong fit for synchronous user-facing operations
- Clear request/response semantics
- Easier to pilot with low-volume traffic
Costs
- Can preserve tight coupling if overused
- Runtime availability still depends on upstream stability
- Encourages chatty integration if boundaries are weak
- Often exposes legacy behaviour too directly
Best used when
- The flow is user-driven and synchronous
- You need immediate response semantics
- The module can present a stable façade while internals evolve
3) Data store seam
This is the most dangerous seam and sometimes the most necessary. The new capability owns a dataset, a replicated read model, or a dedicated store while the legacy application continues to operate.
Strengths
- Strongest boundary for ownership
- Enables independent scaling and schema evolution
- Reduces shared-table pressure
- Makes domain persistence explicit
Costs
- Data migration and synchronisation complexity
- Risk of dual writes and consistency bugs
- Requires careful cutover planning
- Can hide business process dependencies in replication logic
Best used when
- Ownership boundaries are already clear
- Shared tables are blocking delivery
- You need to decouple schema changes from legacy release cycles
Choosing the seam
A simple rule works well:
- If the change is event-driven and can be asynchronous, start with events.
- If the change must respond synchronously, start with an API façade.
- If the main problem is data ownership, introduce a data store seam early.
The seam is not permanent. It is a migration mechanism. The design should make it possible to narrow the seam later, not widen it forever.
When to extract: concrete signals
Extraction should not be based on enthusiasm. It should be based on signals.
Strong extraction signals
Extract a module when several of these are true:
- The same area changes in nearly every release cycle.
- One team waits on another team’s code ownership to ship.
- A shared database table is causing conflicting assumptions.
- Operational incidents repeatedly point to one hotspot.
- Different parts of the business need different release cadences.
- The code path has distinct performance or scaling needs.
- Feature flags or conditional branches have become permanent architecture.
- The test suite for that area is slow, brittle, or expensive to set up.
Weak extraction signals
Do not extract just because:
- a module looks “messy”,
- a class is long,
- a team likes the idea of microservices,
- or another company did it.
Those are organisational emotions, not engineering signals.
Extraction trigger checklist
Use a trigger list, not a slogan:
| Signal | Threshold | Action |
|---|---|---|
| Release dependency | 3+ teams blocked in a quarter | Consider boundary extraction |
| Incident concentration | Same subsystem appears in 30%+ of incidents | Prioritise isolation |
| Schema contention | Shared tables require coordinated changes every sprint | Introduce data ownership boundary |
| Change frequency | Same capability changes in most iterations | Split the capability |
| Latency needs | One path requires materially different response time | Separate execution model |
The threshold numbers are guidance, not law. The point is to make extraction a visible decision instead of a vague aspiration.
ADR template artifact
Architecture decisions should be recorded as decisions, not slides. An ADR gives you a lightweight record of what was chosen, why it was chosen, and when the choice should be revisited.
ADR template
Title:
Status: Proposed / Accepted / Superseded
Date:
Context
Describe the business and technical situation. State the problem in operational terms.
Decision Drivers
List the forces that matter. Use five questions and score them with weighting guidance.
- How much does this reduce delivery risk? — Weight 1 to 5
- How strongly does this improve ownership clarity? — Weight 1 to 5
- How much operational complexity does this add? — Weight 1 to 5, inverse score if higher is worse
- How much does this protect current customer behaviour? — Weight 1 to 5
- How easy is it to reverse or replace later? — Weight 1 to 5
Weighting guidance
- Assign higher weight to drivers tied to customer impact, operational stability, and irreversible cost.
- Keep weights explicit so trade-offs are visible.
- Use a simple matrix if needed: score each option from 1 to 5, multiply by weight, and compare totals.
- If two options are close, examine the highest-risk driver rather than the total score alone.
Options Considered
- Option A: Keep the monolith and reduce internal complexity
- Option B: Extract a bounded module behind an API seam
- Option C: Extract a module with event-driven integration
- Option D: Split data ownership first, then move execution
Decision Made
State the selected option and the reason it wins under the stated drivers.
Consequences
Document the expected benefits and the costs accepted as part of the decision.
Review Trigger
State the condition that should cause the decision to be revisited, such as a change in traffic, ownership, incident rate, or business cadence.
Example ADR skeleton
Below is a compact ADR format: six sections — context, decision drivers, options considered, the decision made, consequences, and the review trigger.
# ADR 019: Extract notifications behind an event seam
## Context
The monolith owns email, SMS, and in-app notifications. Release cadence is blocked by a shared queue implementation and duplicated routing logic.
## Decision Drivers
1. Reduce release coupling — weight 5
2. Preserve current customer behaviour — weight 5
3. Reduce operational risk — weight 4
4. Improve ownership clarity — weight 4
5. Keep reversal cost low — weight 3
## Options Considered
- Keep notifications in the monolith
- Extract notifications behind an API seam
- Extract notifications behind an event bus seam
## Decision Made
Choose the event bus seam because notifications are already asynchronous and multiple downstream consumers need the same fact stream.
## Consequences
- Better ownership and decoupling
- Requires idempotency and schema versioning
- Introduces observable eventual consistency
## Review Trigger
Revisit if synchronous notification workflows become customer-facing or if event lag exceeds agreed SLOs.
The expanded ADR template with the five questions a team should answer before extracting a service is covered in The Architecture Decision Record That Prevents Accidental Microservices.
A modernisation sequence that avoids the rewrite trap
A workable sequence usually looks like this:
- Map the highest-cost capabilities and their dependencies.
- Choose one boundary with clear business value.
- Define the seam and contract.
- Add observability before traffic moves.
- Route a small, low-risk slice through the new path.
- Measure production behaviour, not just test success.
- Expand the slice only after the new path is stable.
- Retire legacy code only when no traffic remains.
This approach is slower than a rewrite pitch deck and faster than a failed rebuild.
Practical guardrails
A modular platform effort stays healthy when it follows these guardrails:
- One boundary at a time.
- One owner per capability.
- One source of truth per business decision.
- No distributed transactions unless the business case is explicit.
- Idempotent consumers for event-driven paths.
- Observable cutovers with rollback plans.
- Shared schemas only by exception, never by habit.
C# 8+ / .NET 8 example: modular boundary with DI
The following example shows a notification module registered as a separate capability behind an interface. It is intentionally simple, but the shape matters: the composition root owns the wiring, the module owns its logic.
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddSingleton<INotificationGateway, SmtpNotificationGateway>();
builder.Services.AddSingleton<NotificationService>();
using var host = builder.Build();
var service = host.Services.GetRequiredService<NotificationService>();
await service.SendWelcomeAsync("customer-123", CancellationToken.None);
public interface INotificationGateway
{
Task SendAsync(string recipientId, string subject, string body, CancellationToken cancellationToken);
}
public sealed class SmtpNotificationGateway : INotificationGateway
{
private readonly ILogger<SmtpNotificationGateway> _logger;
public SmtpNotificationGateway(ILogger<SmtpNotificationGateway> logger) => _logger = logger;
public Task SendAsync(string recipientId, string subject, string body, CancellationToken cancellationToken)
{
// Real delivery would happen here via SmtpClient; the example only logs.
_logger.LogInformation("Sending notification to {RecipientId}: {Subject}", recipientId, subject);
return Task.CompletedTask;
}
}
public sealed class NotificationService
{
private readonly INotificationGateway _gateway;
public NotificationService(INotificationGateway gateway) => _gateway = gateway;
public Task SendWelcomeAsync(string customerId, CancellationToken cancellationToken)
=> _gateway.SendAsync(customerId, "Welcome", "Your account is ready.", cancellationToken);
}
The architectural point is not the SMTP implementation. The point is that the module can later be swapped for an event consumer, an HTTP adapter, or a dedicated service without rewriting the application around it.
Conclusion
Modernisation fails when teams confuse architecture change with application replacement. A modular platform strategy is more disciplined: define a strangler boundary, choose the seam deliberately, and extract only when the signals say the system is ready.
Microservices are not a default destination. They are a response to real distribution and ownership problems. If those problems exist, they can be a good answer. If they do not, the simplest modular structure usually wins.
The rewrite trap is avoided not by talking about agility, but by making bounded decisions one at a time.
Sources
- Strangler Fig Application — Martin Fowler
- Documenting Architecture Decisions — Michael Nygard, Cognitect
- Decomposing monoliths into microservices — AWS Prescriptive Guidance
If you are planning a modernisation programme, start with one capability, one seam, and one ADR. That is usually enough to reveal whether the target shape is a modular monolith, a platform of services, or something in between. Discuss a modernisation architecture → techbuzzz.ru



