
Dal Codice Spaghetti all'Architettura Al Dente: Guida
“Spaghetti code” is almost never born that way. It starts as something small and pragmatic. A controller that “just hits the DB for now.” A static helper that “we’ll clean up later.” A feature that needed to go out yesterday.
Then the company grows, the product evolves, people leave, and suddenly your core system behaves like a haunted house: opening one door triggers five surprises somewhere else.
Here's how to save some real money.
Dal Codice Spaghetti all'Architettura Al Dente: Guida
“Spaghetti code” is almost never born that way. It starts as something small and pragmatic. A controller that “just hits the DB for now.” A static helper that “we’ll clean up later.” A feature that needed to go out yesterday.
Then the company grows, the product evolves, people leave, and suddenly your core system behaves like a haunted house: opening one door triggers five surprises somewhere else.
Everyone talks about clean architecture. Fewer people talk about how to move from a tangled ball of code to something firm but flexible without stopping the business. That’s what we mean by “architettura al dente”: structured enough to hold together under change, but not so rigid that every improvement becomes a multi-quarter project.
When you leave the codebase in “spaghetti mode,” the cost doesn’t just show up as developer frustration. It shows up as real time and real money. Every new feature takes longer because someone has to re-learn hidden rules, tiptoe around side effects, and debug strange regressions. If a change that should take two days routinely turns into two weeks, that’s not just an engineering problem – it’s lost opportunity, slower sales cycles, delayed onboarding for big customers, and a higher burn rate. As the architecture gets more rigid, the marginal cost of every next feature quietly climbs, until the business is paying a premium just to stand still.
Instead of “layer your app” (you’ve seen that post a hundred times), let’s look at three specific moves you can make inside an existing mess, with real C# examples:
- Freeze a seam with a façade
- Pull rules into a “functional core”
- Use the compiler to enforce module boundaries
None of them require microservices. All of them reduce the chance that your next feature breaks something three modules away.
1. Freezing a seam with a façade
In a spaghetti system, the biggest problem often isn’t what the code does but who is allowed to talk to whom. Everything reaches into everything else.
The first step isn’t to rewrite; it’s to freeze. You pick a messy area and say: “from now on, the rest of the code can only talk to this through a narrow doorway.”
Suppose a bunch of different places call some “almost domain-ish” static helpers:
public static class PricingEngine
{
public static decimal CalculateTotal(
decimal basePrice,
decimal discount,
decimal taxRate,
bool isPremiumCustomer)
{
// 20 lines of increasingly mysterious logic…
}
}
And throughout the codebase:
var total = PricingEngine.CalculateTotal(price, discount, tax, isPremium);
Instead of rewriting the pricing logic immediately, create a façade with a stable contract:
public record PriceRequest(
decimal BasePrice,
decimal Discount,
decimal TaxRate,
bool IsPremiumCustomer
);
public record PriceResult(
decimal Total,
decimal TaxAmount,
decimal DiscountApplied
);
public interface IPricingService
{
PriceResult Calculate(PriceRequest request);
}
Then implement it using the old static code:
public class LegacyPricingService : IPricingService
{
public PriceResult Calculate(PriceRequest request)
{
var total = PricingEngine.CalculateTotal(
request.BasePrice,
request.Discount,
request.TaxRate,
request.IsPremiumCustomer);
var taxAmount = total - (request.BasePrice - request.Discount);
return new PriceResult(
Total: total,
TaxAmount: taxAmount,
DiscountApplied: request.Discount);
}
}
Everywhere else, start calling IPricingService instead of PricingEngine directly. You haven’t “fixed” pricing yet, but you’ve created a seam you can test, mock, and eventually replace.
This is al dente thinking: you don’t change the recipe yet; you make sure all the forks go through the same plate.
2. Pulling business rules into a “functional core”
In spaghetti systems, business rules are usually welded to I/O: HTTP, EF, logging, email sending, whatever. That makes them hard to reason about and even harder to test.
A small but powerful move is to define a functional core: operations that take data structures in and return data structures out, with no external side-effects.
Let’s say your order creation logic is buried inside an EF-heavy service. You can surface the rules like this:
public record CreateOrderInput(
string CustomerId,
decimal Subtotal,
decimal Discount,
decimal TaxRate,
bool IsManagerApproved
);
public record CreateOrderDecision(
bool IsAccepted,
string? RejectionReason,
decimal FinalTotal
);
public static class OrderDecider
{
public static CreateOrderDecision Decide(CreateOrderInput input)
{
if (string.IsNullOrWhiteSpace(input.CustomerId))
return new CreateOrderDecision(false, "Missing customer", 0m);
if (input.Subtotal <= 0)
return new CreateOrderDecision(false, "Invalid subtotal", 0m);
if (input.Subtotal > 10000 && !input.IsManagerApproved)
return new CreateOrderDecision(false, "Requires manager approval", 0m);
var discounted = input.Subtotal - input.Discount;
var total = discounted + discounted * input.TaxRate;
return new CreateOrderDecision(true, null, total);
}
}
Now the application service becomes a thin shell:
public class OrderAppService
{
private readonly OrdersDbContext _db;
private readonly IEmailSender _email;
public OrderAppService(OrdersDbContext db, IEmailSender email)
{
_db = db;
_email = email;
}
public async Task<Guid> CreateOrderAsync(CreateOrderInput input, CancellationToken ct)
{
var decision = OrderDecider.Decide(input);
if (!decision.IsAccepted)
throw new InvalidOperationException(decision.RejectionReason);
var order = new Order
{
Id = Guid.NewGuid(),
CustomerId = input.CustomerId,
Total = decision.FinalTotal,
CreatedAt = DateTime.UtcNow
};
_db.Orders.Add(order);
await _db.SaveChangesAsync(ct);
await _email.SendAsync(
input.CustomerId,
"Order created",
$"Your total is {decision.FinalTotal:C}",
ct);
return order.Id;
}
}
Suddenly, your core rules live in one place you can test with pure in-memory unit tests. Changing VAT logic or approval thresholds is now a local, predictable change.
You’re still in the same monolith, but part of it is now firm. That’s al dente.
3. Letting the compiler defend your boundaries
One reason spaghetti appears is that nothing stops developers from reaching across the codebase and calling whatever they want. If everything is in one project, any class can talk to any other class.
You don’t need microservices to fix that. You can use projects as module boundaries and the compiler as your enforcer.
For example, you might split your solution into:
- MyApp.Domain
- MyApp.Application
- MyApp.Infrastructure
- MyApp.Api
Then wire references one way only:
<!-- MyApp.Domain.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
</Project>
<!-- MyApp.Application.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\MyApp.Domain\MyApp.Domain.csproj" />
</ItemGroup>
</Project>
<!-- MyApp.Infrastructure.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\MyApp.Application\MyApp.Application.csproj" />
</ItemGroup>
</Project>
<!-- MyApp.Api.csproj -->
<Project Sdk="Microsoft.NET.Sdk.Web">
<ItemGroup>
<ProjectReference Include="..\MyApp.Application\MyApp.Application.csproj" />
<ProjectReference Include="..\MyApp.Infrastructure\MyApp.Infrastructure.csproj" />
</ItemGroup>
</Project>
Now, if someone tries to reference Infrastructure from Domain “just for this quick thing,” it doesn’t compile. You’ve moved from “please don’t do that” to “you literally can’t.”
You can apply the same idea horizontally: separate billing, orders, reporting into different projects, and only allow dependencies in one direction. Over time, this exposes who depends on whom in a way a diagram never will.
When is your architecture “al dente” enough?
The point of all this is not to create textbook purity. It’s to reach a state where:
- You know where key rules live.
- You can change them without hunting through five controllers and three helpers.
- The codebase pushes back a little when you try to cut across boundaries.
At that point, you can have a serious conversation about what comes next: extracting a service, introducing messaging, or just continuing to harden the modular monolith you now have.
And that’s the real move: not jumping straight from “spaghetti” to “perfect hexagonal microservice architecture,” but getting to a firm, chewable, al dente architecture that can actually grow with the business.
Discussion Board Coming Soon
We're building a discussion board where you can share your thoughts and connect with other readers. Stay tuned!
Ready for CTO-level Leadership Without a Full-time Hire?
Let's discuss how Fractional CTO support can align your technology, roadmap, and team with the business, unblock delivery, and give you a clear path for the next 12 to 18 months.
Or reach us at: info@sharplogica.com