Caching in .NET Backend Systems: Improving Performance Without Breaking Consistency
How to improve performance without creating stale data, hidden coupling, or consistency problems
Caching in .NET Backend Systems: Improving Performance Without Breaking Consistency

How to improve performance without creating stale data, hidden coupling, or consistency problems
In Part 1, I wrote about boundaries. In Part 2, I wrote about aggregates and invariants. In Part 3, I wrote about domain events. In Part 4, I wrote about commands and queries. In Part 5, I wrote about modular monoliths and microservices. In Part 6, I wrote about synchronous vs asynchronous communication. In Part 7, I wrote about HTTP, messaging, and gRPC. In Part 8, I wrote about resiliency in backend systems. In Part 9, I wrote about observability. In Part 10, I wrote about transactions and eventual consistency. In Part 11, I wrote about idempotency.
Now we come to another common production topic:
caching.
Caching is one of the most useful techniques for improving backend performance.
But it is also one of the easiest ways to create subtle production problems.
A cache can make a system faster.
It can reduce database load.
It can improve response times.
It can help protect downstream services.
But if caching is added carelessly, it can also create:
- stale data
- incorrect user experience
- hidden coupling
- difficult debugging
- inconsistent reads
- security leaks
- invalidation problems
- production behavior that is hard to explain
So the real question is not:
Should we use caching?
The better question is:
What should we cache, where should we cache it, and how will we keep it safe?
That is what this article is about.
The practical scenario
Let’s continue with a telemedicine backend.
A telemedicine system may have many read-heavy operations:
- search doctors
- view doctor profile
- load doctor availability
- view upcoming consultations
- show patient dashboard
- load reference data such as specialties
- show consultation history
Some of these are good caching candidates.
Others are risky.
For example, caching a list of doctor specialties is usually safe.
Caching a patient’s payment status for too long may be dangerous.
Caching doctor availability without proper invalidation can cause double-booking confusion.
That is why caching needs architecture thinking.
What caching is trying to solve
Caching is useful when the backend repeatedly performs expensive work.
That expensive work may be:
- database queries
- external API calls
- computed results
- search operations
- aggregation queries
- report generation
- read models used frequently by the UI
Instead of recalculating or reloading the same data every time, the system stores a copy temporarily and reuses it.
A simple flow looks like this:
Request comes in
|
v
Check cache
|
v
Cache hit? Return cached result
|
v
Cache miss? Load from source
|
v
Store result in cache
|
v
Return result
This is the basic idea.
But the hard part is deciding what belongs in the cache and how long it should stay there.
Caching is not a replacement for good design
Caching should not be the first answer to every performance problem.
Before adding caching, ask:
- Is the database query badly written?
- Is the table missing an index?
- Are we loading too much data?
- Are we returning entities instead of DTOs?
- Are we making unnecessary external calls?
- Is the API doing too much work in one request?
- Is pagination missing?
Caching can hide inefficient design temporarily.
But if the underlying query is poor, caching may only delay the problem.
A cache should improve a good design.
It should not cover up a broken one.
What should be cached?
Good caching candidates usually have these characteristics:
- read frequently
- change infrequently
- expensive to calculate or retrieve
- safe to serve slightly stale for a short period
- not highly sensitive unless carefully scoped
- not part of a critical immediate decision
Examples in a telemedicine system:
- doctor specialty list
- public doctor profile summary
- doctor search results
- country/city reference data
- public clinic information
- frequently used read models
- dashboard summaries with acceptable delay
Bad caching candidates:
- current payment confirmation state
- one-time access tokens
- highly sensitive medical notes
- data with strict real-time correctness
- authorization decisions without careful handling
- doctor availability used for final booking decisions
The rule is simple:
Cache data that can safely be reused. Do not cache data that must be correct right now unless you have a strong invalidation strategy.
Cache reads, not commands
Caching usually belongs on the read side, not the write side.
This connects directly to CQRS.
Commands change the system.
Queries read from the system.
Caching fits naturally around queries because query results are often reused.
For example:
SearchDoctorsQueryGetDoctorProfileQueryGetPatientDashboardQueryGetSpecialtiesQuery
These are good places to consider caching.
But commands such as:
BookConsultationCommandConfirmPaymentCommandCompleteConsultationCommand
should not rely on cached data for core business decisions.
A command should protect correctness.
A query can optimize read performance.
Basic cache abstraction
In a .NET modular monolith, I would usually hide cache implementation details behind an abstraction.
For example:
public interface ICacheService
{
Task<T?> GetAsync<T>(
string key,
CancellationToken cancellationToken);
Task SetAsync<T>(
string key,
T value,
TimeSpan expiration,
CancellationToken cancellationToken);
Task RemoveAsync(
string key,
CancellationToken cancellationToken);
}
This keeps application code independent from the actual cache provider.
The implementation could use:
- in-memory cache
- Redis
- distributed cache
- hybrid cache approach
The application layer should not care.
It should depend on the abstraction.
Cache-aside pattern
The most common caching pattern is cache-aside.
The application checks the cache first.
If the data exists, it returns it.
If not, it loads the data from the database, stores it in cache, and returns it.
Example:
public sealed class GetDoctorProfileHandler
: IQueryHandler<GetDoctorProfileQuery, DoctorProfileDto>
{
private readonly ICacheService _cache;
private readonly IDoctorReadRepository _repository;
public GetDoctorProfileHandler(
ICacheService cache,
IDoctorReadRepository repository)
{
_cache = cache;
_repository = repository;
}
public async Task<DoctorProfileDto> Handle(
GetDoctorProfileQuery query,
CancellationToken cancellationToken)
{
var cacheKey = $"doctor-profile:{query.DoctorId}";
var cachedProfile = await _cache.GetAsync<DoctorProfileDto>(
cacheKey,
cancellationToken);
if (cachedProfile is not null)
return cachedProfile;
var profile = await _repository.GetDoctorProfileAsync(
query.DoctorId,
cancellationToken);
if (profile is null)
throw new NotFoundException("Doctor profile was not found.");
await _cache.SetAsync(
cacheKey,
profile,
TimeSpan.FromMinutes(10),
cancellationToken);
return profile;
}
}
This improves performance for repeated profile reads.
But it also introduces a responsibility:
If the doctor profile changes, how do we avoid serving stale data for too long?
That is where cache invalidation comes in.
Cache expiration is not a full invalidation strategy
A common mistake is relying only on TTL.
TTL means “time to live.”
For example:
TimeSpan.FromMinutes(10)
This means the cached data expires after 10 minutes.
TTL is useful.
But TTL alone may not be enough.
If a doctor updates their profile, should users see the old profile for 10 more minutes?
Maybe yes.
Maybe not.
That depends on the business.
For some data, a 10-minute delay is acceptable.
For other data, even 30 seconds may be too long.
So every cache entry needs a freshness decision.
Cache invalidation on write
When data changes, the system should often remove or refresh related cache entries.
For example, when a doctor updates their profile, remove the cached profile.
public sealed class UpdateDoctorProfileHandler
: ICommandHandler<UpdateDoctorProfileCommand>
{
private readonly IDoctorRepository _repository;
private readonly ICacheService _cache;
private readonly IUnitOfWork _unitOfWork;
public UpdateDoctorProfileHandler(
IDoctorRepository repository,
ICacheService cache,
IUnitOfWork unitOfWork)
{
_repository = repository;
_cache = cache;
_unitOfWork = unitOfWork;
}
public async Task Handle(
UpdateDoctorProfileCommand command,
CancellationToken cancellationToken)
{
var doctor = await _repository.GetByIdAsync(
command.DoctorId,
cancellationToken);
if (doctor is null)
throw new DomainException("Doctor was not found.");
doctor.UpdateProfile(
command.DisplayName,
command.Bio,
command.Specialization);
await _unitOfWork.SaveChangesAsync(cancellationToken);
await _cache.RemoveAsync(
$"doctor-profile:{command.DoctorId}",
cancellationToken);
}
}
This works for simple cases.
But there is an important detail.
The database update should succeed before removing the cache.
Otherwise, you may invalidate the cache even though the write failed.
Invalidation through domain events
In a modular monolith, cache invalidation can also be handled through domain events.
For example, when a doctor's profile changes, the domain raises:
public sealed record DoctorProfileUpdatedDomainEvent(
Guid DoctorId,
DateTime OccurredAtUtc
) : IDomainEvent;
Then a handler removes the related cache entries.
public sealed class DoctorProfileUpdatedCacheInvalidationHandler
: IDomainEventHandler<DoctorProfileUpdatedDomainEvent>
{
private readonly ICacheService _cache;
public DoctorProfileUpdatedCacheInvalidationHandler(ICacheService cache)
{
_cache = cache;
}
public async Task Handle(
DoctorProfileUpdatedDomainEvent domainEvent,
CancellationToken cancellationToken)
{
await _cache.RemoveAsync(
$"doctor-profile:{domainEvent.DoctorId}",
cancellationToken);
await _cache.RemoveAsync(
"doctor-search:popular",
cancellationToken);
}
}
This keeps cache invalidation separate from the main command handler.
But be careful.
Do not make the domain model depend on cache.
The domain raises a business event.
The application or infrastructure layer decides that cache invalidation is one reaction.
Cache key design matters
Bad cache keys create bugs.
A cache key should clearly include the data identity and important query parameters.
For example:
doctor-profile:doctorId
That is fine for one doctor's profile.
But search results need more detail.
doctor-search:specialty=cardiology:city=dubai:page=1:size=20
If the cache key does not include all important filters, the backend may return the wrong cached result.
Example mistake:
doctor-search
This is too generic.
It may return Dubai doctors to a user searching in Abu Dhabi.
Or cardiologists to a user searching for dermatologists.
Cache keys must represent the query accurately.
Cache key helper
A small helper can reduce inconsistent key construction.
public static class CacheKeys
{
public static string DoctorProfile(Guid doctorId)
=> $"doctor-profile:{doctorId}";
public static string DoctorSearch(
string specialty,
string city,
int page,
int pageSize)
=> $"doctor-search:specialty={specialty.ToLowerInvariant()}:city={city.ToLowerInvariant()}:page={page}:size={pageSize}";
public static string Specialties()
=> "reference:specialties";
}
Centralizing key creation helps avoid bugs caused by slightly different key formats in different parts of the codebase.
This is especially useful in larger systems.
Caching query results
Let’s look at a doctor search query.
public sealed record SearchDoctorsQuery(
string Specialty,
string City,
int Page,
int PageSize
) : IQuery<IReadOnlyList<DoctorSearchResultDto>>;
The query handler can cache search results for a short time.
public sealed class SearchDoctorsHandler
: IQueryHandler<SearchDoctorsQuery, IReadOnlyList<DoctorSearchResultDto>>
{
private readonly ICacheService _cache;
private readonly IDoctorReadRepository _repository;
public SearchDoctorsHandler(
ICacheService cache,
IDoctorReadRepository repository)
{
_cache = cache;
_repository = repository;
}
public async Task<IReadOnlyList<DoctorSearchResultDto>> Handle(
SearchDoctorsQuery query,
CancellationToken cancellationToken)
{
var cacheKey = CacheKeys.DoctorSearch(
query.Specialty,
query.City,
query.Page,
query.PageSize);
var cachedResult = await _cache.GetAsync<IReadOnlyList<DoctorSearchResultDto>>(
cacheKey,
cancellationToken);
if (cachedResult is not null)
return cachedResult;
var doctors = await _repository.SearchAsync(
query.Specialty,
query.City,
query.Page,
query.PageSize,
cancellationToken);
await _cache.SetAsync(
cacheKey,
doctors,
TimeSpan.FromMinutes(2),
cancellationToken);
return doctors;
}
}
Why only 2 minutes?
Because doctor search results may change when:
- doctors update profiles
- availability changes
- doctor status changes
- search ranking changes
Short TTLs are often safer for search results.
Reference data can usually use longer TTLs.
Different data needs different cache durations
Do not use one expiration value for everything.
Different data have different freshness needs.
| Data | Suggested cache duration | Notes |
| --------------------- | -----------------------: | ---------------------------------- |
| Specialty list | 1–24 hours | Changes rarely |
| Public doctor profile | 5–15 minutes | Invalidate on update |
| Doctor search results | 1–5 minutes | Search data changes more often |
| Patient dashboard | 30–60 seconds | User-specific and sensitive |
| Availability preview | Very short | Must not be used for final booking |
| Payment status | Avoid or very short | Correctness matters |
The cache duration should reflect business risk.
Do not use cache for final booking decisions
This is very important.
A doctor availability screen can use cached data for display.
But the final booking command must check the source of truth.
For example:
public sealed class BookConsultationHandler
: ICommandHandler<BookConsultationCommand, Guid>
{
private readonly IDoctorScheduleRepository _scheduleRepository;
private readonly IConsultationRepository _consultationRepository;
private readonly IUnitOfWork _unitOfWork;
public async Task<Guid> Handle(
BookConsultationCommand command,
CancellationToken cancellationToken)
{
var schedule = await _scheduleRepository.GetByDoctorIdAsync(
command.DoctorId,
cancellationToken);
if (schedule is null)
throw new DomainException("Doctor schedule was not found.");
schedule.EnsureSlotIsAvailable(command.ScheduledAtUtc);
var consultation = Consultation.Request(
command.PatientId,
command.DoctorId,
command.ScheduledAtUtc);
await _consultationRepository.AddAsync(consultation, cancellationToken);
await _unitOfWork.SaveChangesAsync(cancellationToken);
return consultation.Id;
}
}
Notice what is missing:
No cache is used to make the final booking decision.
That is intentional.
The cache can help the user browse availability.
But the command must protect correctness using the real consistency boundary.
In-memory cache vs distributed cache
In .NET, you may use an in-memory cache or a distributed cache.
They solve different problems.
In-memory cache
Stored inside one application instance.
Good for:
- small local reference data
- single-instance applications
- lightweight temporary values
- very fast local lookup
But it has limitations:
- Each instance has its own cache
- The cache is lost on restart
- Invalidation is harder across multiple instances
- Not ideal for scaled-out applications
Distributed cache
Stored outside the application process, often in Redis.
Good for:
- multiple app instances
- shared cache state
- scaled-out systems
- consistent cache behavior across servers
But it adds:
- network dependency
- serialization cost
- operational complexity
- failure scenarios
Use the simplest option that fits the deployment model.
For a production system with multiple app instances, a distributed cache is usually more realistic.
Distributed cache implementation example
A simple Redis-based implementation can sit behind ICacheService.
public sealed class DistributedCacheService : ICacheService
{
private readonly IDistributedCache _distributedCache;
public DistributedCacheService(IDistributedCache distributedCache)
{
_distributedCache = distributedCache;
}
public async Task<T?> GetAsync<T>(
string key,
CancellationToken cancellationToken)
{
var json = await _distributedCache.GetStringAsync(
key,
cancellationToken);
if (string.IsNullOrWhiteSpace(json))
return default;
return JsonSerializer.Deserialize<T>(json);
}
public async Task SetAsync<T>(
string key,
T value,
TimeSpan expiration,
CancellationToken cancellationToken)
{
var json = JsonSerializer.Serialize(value);
await _distributedCache.SetStringAsync(
key,
json,
new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = expiration
},
cancellationToken);
}
public async Task RemoveAsync(
string key,
CancellationToken cancellationToken)
{
await _distributedCache.RemoveAsync(key, cancellationToken);
}
}
This keeps Redis or distributed cache details out of query handlers.
That is important for maintainability.
Cache failures should not always break the request
A cache is often an optimization.
If the cache is unavailable, some requests can still load from the database.
For example:
public sealed class SafeCacheService : ICacheService
{
private readonly ICacheService _inner;
private readonly ILogger<SafeCacheService> _logger;
public SafeCacheService(
ICacheService inner,
ILogger<SafeCacheService> logger)
{
_inner = inner;
_logger = logger;
}
public async Task<T?> GetAsync<T>(
string key,
CancellationToken cancellationToken)
{
try
{
return await _inner.GetAsync<T>(key, cancellationToken);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Cache get failed for key {CacheKey}.", key);
return default;
}
}
public async Task SetAsync<T>(
string key,
T value,
TimeSpan expiration,
CancellationToken cancellationToken)
{
try
{
await _inner.SetAsync(key, value, expiration, cancellationToken);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Cache set failed for key {CacheKey}.", key);
}
}
public async Task RemoveAsync(
string key,
CancellationToken cancellationToken)
{
try
{
await _inner.RemoveAsync(key, cancellationToken);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Cache remove failed for key {CacheKey}.", key);
}
}
}
This is useful when the cache is not the source of truth.
But be careful.
Some caches may be part of a critical path.
If the cache contains required session state or rate-limit state, failure behavior may be different.
Again, architecture depends on the role of the cache.
Avoid caching sensitive data carelessly
Caching can create security problems.
For example:
- patient dashboard data
- consultation notes
- prescription details
- payment details
- personal health information
If this data is cached, the key must be user-scoped and tenant-scoped.
Example:
patient-dashboard:tenantId:userId
Not:
patient-dashboard
A bad key can leak one user’s data to another user.
For sensitive data, ask:
- Should this be cached at all?
- How long should it live?
- Is the cache encrypted?
- Is the key scoped correctly?
- Can another tenant access this by mistake?
- Is it removed when permissions change?
Caching is not only a performance concern.
It is also a security concern.
Tenant-aware cache keys
In multi-tenant systems, cache keys must include tenant identity.
public static class TenantCacheKeys
{
public static string DoctorProfile(
Guid tenantId,
Guid doctorId)
=> $"tenant:{tenantId}:doctor-profile:{doctorId}";
public static string PatientDashboard(
Guid tenantId,
Guid patientId)
=> $"tenant:{tenantId}:patient-dashboard:{patientId}";
}
Without tenant-aware keys, different tenants may accidentally share cached data.
That is a serious isolation failure.
In SaaS systems, the tenant ID should be part of almost every cache key.
Cache stampede problem
A cache stampede happens when many requests try to reload the same cache entry at the same time.
Example:
- Popular cache entry expires
- Many users request it at once
- All requests miss the cache
- All requests hit the database
- The database receives a sudden spike
Caching was supposed to reduce load, but now it causes a load spike.
One way to reduce this is to use a lock around the cache population for expensive keys.
A simplified example:
public sealed class CachedQueryService
{
private static readonly SemaphoreSlim _lock = new(1, 1);
private readonly ICacheService _cache;
public CachedQueryService(ICacheService cache)
{
_cache = cache;
}
public async Task<T> GetOrCreateAsync<T>(
string key,
Func<Task<T>> factory,
TimeSpan expiration,
CancellationToken cancellationToken)
{
var cached = await _cache.GetAsync<T>(key, cancellationToken);
if (cached is not null)
return cached;
await _lock.WaitAsync(cancellationToken);
try
{
cached = await _cache.GetAsync<T>(key, cancellationToken);
if (cached is not null)
return cached;
var value = await factory();
await _cache.SetAsync(
key,
value,
expiration,
cancellationToken);
return value;
}
finally
{
_lock.Release();
}
}
}
This is simplified.
In distributed systems, you may need a distributed lock or other strategy.
The main idea is:
Avoid letting every cache miss hit the source of truth at the same time.
Caching and hidden coupling
Caching can create hidden coupling when one module depends on another module’s cached data structure.
For example:
- The payments module reads the cached consultation DTO directly
- The notifications module depends on the cached patient dashboard shape
- The reporting module assumes a cache key created by Scheduling
This is risky.
Cache should not become a secret integration layer between modules.
In a modular monolith, modules should communicate through clear contracts, not through shared cache assumptions.
Bad:
Payments module reads Scheduling module's internal cache key directly
Better:
Payments module asks Scheduling through an application contract or consumes a published event
The cache is an optimization.
It should not become the architecture.
Caching and observability
Caching must be observable.
Otherwise, production behavior becomes hard to explain.
Useful cache metrics include:
- cache hit rate
- cache miss rate
- cache set count
- cache remove count
- cache failures
- cache latency
- cache size
- stale data incidents
- stampede events
Useful logs include:
logger.LogInformation(
"Cache miss for key {CacheKey}. Loading from source.",
cacheKey);
And:
logger.LogWarning(
"Cache invalidated for doctor profile {DoctorId}.",
doctorId);
Do not log sensitive cache values.
Log keys carefully if they contain personal or tenant-specific information.
Caching and resiliency
Caching can support resiliency.
For example, if a secondary dependency is temporarily down, cached data may allow the system to degrade gracefully.
But this must be intentional.
For example:
- Show the cached doctor profile if the profile service is slow
- Show cached reference data if the database is under pressure
- Show cached dashboard summary with a “last updated” timestamp
But do not use stale cache to make critical decisions.
For example:
- Do not confirm payment based on stale cache
- Do not book a doctor slot based only on cached availability
- Do not authorize sensitive access based on old permission data without a careful strategy
Caching can help resilience, but only when stale data is acceptable.
Write-through and write-behind caching
Cache-aside is common, but there are other patterns.
Write-through cache
The application writes to the cache and the database together.
This can keep the cache fresh, but it increases write complexity.
Write-behind cache
The application writes to the cache first, and the database is updated later.
This can improve performance but is risky for business-critical data.
For most business systems, I prefer cache-aside for read optimization.
It is simpler and keeps the database as the source of truth.
Write-behind should be used very carefully because it can lose or delay critical writes.
The source of truth must remain clear
A cache should usually not be the source of truth.
The source of truth is usually:
- database
- event store
- external authoritative provider
- domain-owned persistence
The cache is a copy.
This distinction matters.
When production behavior is confusing, teams should know where the true state lives.
If no one knows whether the database or cache is authoritative, the system becomes dangerous.
A simple rule:
Cache is for speed. The domain state is for truth.
Common mistakes
Mistake 1: Caching everything
Not everything should be cached.
Cache only where there is a real performance or resilience benefit.
Mistake 2: Using cache for business decisions
Critical commands should validate against the source of truth.
Do not book slots or confirm payments based only on cache.
Mistake 3: Weak cache keys
Cache keys must include relevant filters, user IDs, tenant IDs, and pagination values.
Mistake 4: No invalidation strategy
TTL alone is not always enough.
Important changes may need explicit invalidation.
Mistake 5: Cache as module integration
Do not use cache as a hidden communication channel between modules.
Mistake 6: Caching sensitive data without scope
User-specific and tenant-specific data must be scoped carefully.
Mistake 7: No observability
If you cannot see cache hits, misses, failures, and invalidations, debugging becomes difficult.
Practical checklist
Before adding cache, ask:
- What problem are we solving?
- Is this data read frequently?
- Is it expensive to load?
- How often does it change?
- How stale can it safely be?
- Is it user-specific?
- Is it tenant-specific?
- What should the cache key include?
- What invalidates the cache?
- What happens if the cache is unavailable?
- Is this cache used in a command or query?
- Is this cache observable?
- Could this create hidden coupling?
These questions help prevent caching from becoming a production problem.
Telemedicine caching summary
| Data | Cache? | Notes |
| --------------------------- | --------------- | ------------------------------------- |
| Doctor specialties | Yes | Long TTL, rarely changes |
| Public doctor profile | Yes | Invalidate on profile update |
| Doctor search results | Yes | Short TTL, include filters in key |
| Patient dashboard | Maybe | Short TTL, user + tenant scoped |
| Doctor availability preview | Maybe | Very short TTL, not for final booking |
| Payment status | Usually avoid | Correctness matters |
| Consultation notes | Avoid or strict | Sensitive medical data |
| Prescription details | Avoid or strict | Sensitive and access-controlled |
The goal is not to cache as much as possible.
The goal is to cache safely.
Final thought
Caching is one of the most useful tools in backend architecture.
But it is not just a performance trick.
It is an architectural decision.
A good caching strategy improves performance while still respecting correctness, security, consistency, and module boundaries.
A poor caching strategy creates stale data, hidden coupling, confusing behavior, and production incidents.
The strongest backend systems treat cache as an optimization, not as the source of truth.
They cache read models carefully.
They invalidate intentionally.
They use tenant-aware and user-aware keys.
They observe cache behavior in production.
And they never let caching weaken core business correctness.
Because caching should make the system faster.
It should not make the system harder to trust.
Coming next
In Part 13, I will cover:
Security Boundaries in Backend Architecture How to design authentication, authorization, tenant isolation, secrets, and trust boundaries into backend systems.
메타데이터
- post_id
- 5df14293c9db
- slug
- caching-in-net-backend-systems-improving-performance-without-breaking-consistency-5df14293c9db
- url
- https://medium.com/@oshadhaj/caching-in-net-backend-systems-improving-performance-without-breaking-consistency-5df14293c9db
- canonical_url
- https://medium.com/@oshadhaj/caching-in-net-backend-systems-improving-performance-without-breaking-consistency-5df14293c9db
- author_url
- https://medium.com/@oshadhaj
- status
- ok
- fetched_at
- 2026-06-24 16:30:55