Architecture Tests: Stop Trusting Your Architecture Diagram

Your architecture diagram says:

API → Application → Domain
         ↑
   Infrastructure

Your code might have other ideas.

Someone adds:

Domain → Infrastructure

Then:

Application → API

Six months later, the diagram is still beautiful.

One way to protect important boundaries is with architecture tests.

For example, you can define rules such as:

Domain must not depend on Infrastructure

Application must not depend on API

Controllers must not access repositories directly

Then run those rules as part of your test suite and CI pipeline.

Architecture tests are useful for enforcing things such as:

  • Layer dependencies
  • Namespace boundaries
  • Module isolation
  • Naming conventions
  • Dependency restrictions
  • Clean Architecture rules

They don't prove that your architecture is good.

They prove that specific structural rules you decided were important are still being followed.

Documentation describes the intended architecture.

Tests can check whether the code agrees.

If an architecture rule matters enough to put on the diagram, it might matter enough to test.

Your Retry Policy Might Be Multiplying an Outage

Retries improve resilience when failures are temporary.

They can also increase load exactly when a dependency is already struggling.

Imagine a service receiving:

1,000 requests/sec

The dependency starts failing.

Your policy retries every request three times.

Now the dependency might see something closer to:

Original traffic:  1,000
Retry #1:          1,000
Retry #2:          1,000
Retry #3:          1,000
                    -----
Potential traffic: 4,000 requests/sec

And that's before considering retries at multiple layers.

Service A retries Service B.

Service B retries Service C.

The client retries Service A.

A reliability mechanism can become a traffic multiplier.

Good retry policies usually consider:

  • Whether the failure is transient
  • Exponential backoff
  • Jitter
  • Maximum attempts
  • Retry budgets
  • Request deadlines
  • Retry-After guidance
  • Whether another layer is already retrying

Retries don't create capacity.

If a dependency is overloaded, sending it more requests immediately might not be the recovery strategy you want.

Retry when another attempt has a reasonable chance of succeeding.

Then give the dependency some time before asking again.

Your Cache Key Is Part of Your Architecture

Caching isn't only about deciding what data to store.

You also need to decide what makes that data unique.

Suppose you cache product information:

product:42

Looks reasonable.

Then you discover that the product 42 has different prices by country:

product:42:PL
product:42:DE

Then prices differ by currency:

product:42:PL:PLN
product:42:DE:EUR

Then the application becomes multi-tenant:

tenant:17:product:42:PL:PLN

Your cache key quietly contains assumptions about your data model.

It determines:

  • What data is considered equivalent
  • Which requests can share cached data
  • Tenant and user isolation
  • How invalidation works
  • Cache cardinality
  • Whether callers receive the correct result

A missing dimension can cause incorrect cache hits.

Too many dimensions can destroy your hit rate and create excessive cardinality.

Before writing:

cache.Set("products", value)

Ask what uniquely identifies the value you're caching.

Your cache doesn't understand your domain.

Your cache key has to explain it.

Your Idempotency Key Defines the Operation

Adding an idempotency key to an API doesn't automatically make the operation idempotent.

The key needs to identify the operation you want to execute once.

Your Idempotency Key Defines the Operation

Consider payment creation:

POST /payments
Idempotency-Key: order-123-payment

The client times out and retries:

POST /payments
Idempotency-Key: order-123-payment

The server recognizes the same logical operation and returns the previous result instead of creating another payment.

Now imagine generating a new key for every retry:

Request 1 → 7f9a...
Request 2 → 82bc...
Request 3 → a14e...

From the server's perspective, those are three different operations.

A good idempotency key should match the scope of the business operation.

Think about:

  • What exactly should happen once?
  • How long should the key remain valid?
  • Which caller owns the key?
  • Should the same key with different payloads be rejected?
  • Where is the result of the original operation stored?

Idempotency isn't about making requests unique.

It is about recognizing when multiple requests represent the same operation.

If every retry gets a new identity, the server has no reason to know it's a retry.

How to Prevent Concurrent Updates from Overwriting Each Other

Two users read the same record:

  • Both modify it
  • Both save it

Who wins?

How to Prevent Concurrent Updates from Overwriting Each Other

Without concurrency protection, the second write might silently overwrite the first.

One common solution is optimistic concurrency.

For example, keep a version number with the record:

Order
Id: 42
Status: Pending
Version: 7

Update it only if the version is still 7:

UPDATE Orders
SET Status = 'Completed',
    Version = 8
WHERE Id = 42
  AND Version = 7;

If zero rows are updated, somebody changed the record after you read it.

Your application can then reject, retry, merge, or ask the user to resolve the conflict.

If multiple actors can update the same data, checking whether it changed before overwriting it is often useful.

How to Make Slow Code Easier to Optimize

Application feels slow?

Before optimizing it, find out what is slow.

Use profiling, tracing, metrics, or benchmarks to measure where the application spends its time.

How to Make Slow Code Easier to Optimize

You might discover that a request spends:

Validation        4 ms
Business logic   12 ms
Database        680 ms
Serialization     8 ms

Optimizing 12 ms of business logic probably won't fix a 700 ms request.

Measure things like:

  • Execution time
  • Database calls
  • External requests
  • CPU usage
  • Memory allocations
  • Lock contention

Then optimize the part responsible for the problem.

Performance optimization becomes much easier once you know what needs to become faster.

How to Avoid Processing the Same Message Twice

Using a message broker?

Assume a message might arrive more than once.

One common approach is to make the consumer idempotent.

For example:

if (await processedMessages.ExistsAsync(message.Id))
{
    return;
}

await ProcessMessageAsync(message);

await processedMessages.AddAsync(message.Id);
How to Avoid Processing the Same Message Twice

An idempotent handler produces the same result even when the same message is delivered repeatedly.

Common techniques include:

  • Store processed message IDs
  • Use unique database constraints
  • Use idempotency keys
  • Make updates naturally idempotent
  • Execute state changes and deduplication atomically where needed

At-least-once delivery means duplicates are part of the design problem.

If the same message arrives twice, processing it twice should not create two different business outcomes.

How to Avoid Hardcoding Application Settings

Have values that change between environments?

Put them in the configuration.

Instead of:

var apiUrl = "https://prod-api.example.com";
var timeout = 30;

Read them from configuration:

{
  "ExternalApi": {
    "Url": "https://api.example.com",
    "TimeoutSeconds": 30
  }
}

Then bind them to options:

builder.Services.Configure<ExternalApiOptions>(
    builder.Configuration.GetSection("ExternalApi"));
How to Avoid Hardcoding Application Settings

Configuration works well for:

  • URLs
  • Timeouts
  • Feature settings
  • Connection strings
  • Environment-specific values

Secrets should usually live in a dedicated secret store rather than source code.

If a value changes without requiring a code change, configuration is usually a better home for it.

How to Make Database Queries Easier to Debug

ORM query behaving differently than expected?

Look at the generated SQL.

In EF Core, you can inspect a query with:

var query = context.Orders
    .Where(x => x.Status == OrderStatus.Completed)
    .OrderByDescending(x => x.CreatedAt);

var sql = query.ToQueryString();
How to Make Database Queries Easier to Debug

Generated SQL helps you see:

  • Which columns are selected
  • Which joins were generated
  • Where filters are applied
  • How ordering works
  • Whether the query matches your assumptions

LINQ is convenient, but the database still executes SQL.

When a database query is slow or surprising, looking at the database query is often useful.

How to Update Rows Without Loading Them in EF Core

Need to update many rows in EF Core?

You don't always need to load them first.

Instead of:

var users = await context.Users
    .Where(x => !x.IsActive)
    .ToListAsync();

foreach (var user in users)
{
    user.Status = UserStatus.Disabled;
}

await context.SaveChangesAsync();

You can update matching rows directly:

await context.Users
    .Where(x => !x.IsActive)
    .ExecuteUpdateAsync(setters => setters
        .SetProperty(x => x.Status, UserStatus.Disabled));
How to Update Rows Without Loading Them in EF Core

ExecuteUpdateAsync() is useful when:

  • You need to update many rows
  • You don't need the entities in memory
  • You don't need change tracking
  • The update can be expressed as a database operation
  • You want to avoid unnecessary round trips and allocations

The update runs directly in the database.

If you don't need the entities, loading them before updating them is optional.