What does context engineering have to do with shipping better code faster? CodeRabbit explains in The art and science of context engineering for AI code reviews. Give it a read or try CodeRabbit today and cut code review time and bugs in half.
Meet Auggie CLI. Augment Code is bringing the power of its AI coding agent and industry-leading context engine right to your terminal. You can use Auggie in a standalone interactive terminal session alongside your favorite editor or in any part of your software development stack. Auggie CLI is in beta...for now.
As developers, we love abstractions. Repositories, services, mappers, wrappers. They make our code look "clean," they promise testability, and they give us the sense that we're building something flexible.
Every abstraction is a loan. You pay interest the moment you write it.
Some abstractions earn their keep by isolating real volatility and protecting your system from change. Others quietly pile up complexity, slow down onboarding, and hide performance problems behind layers of indirection.
Let's explore when abstractions pay dividends and when they become technical debt.
When Abstractions Pay Off
The best abstractions isolate true volatility, the parts of your system that you genuinely expect to change.
Example: Payment Processing
Your core business logic shouldn't depend directly on Stripe's SDK. If you ever switch to Adyen or Braintree, you don't want that decision rippling through every corner of your codebase. Here, an abstraction makes perfect sense:
public interface IPaymentProcessor
{
Task ProcessAsync(Order order, CancellationToken ct);
}
public class StripePaymentProcessor : IPaymentProcessor
{
public async Task ProcessAsync(Order order, CancellationToken ct)
{
// Stripe-specific implementation
// Handle webhooks, error codes, etc.
}
}
public class AdyenPaymentProcessor : IPaymentProcessor
{
public async Task ProcessAsync(Order order, CancellationToken ct)
{
// Adyen-specific implementation
// Different API, same business outcome
}
}
Now your business logic can stay focused on the domain:
public class CheckoutService(IPaymentProcessor processor)
{
public Task CheckoutAsync(Order order, CancellationToken cancellationToken) =>
processor.ProcessAsync(order, cancellationToken);
}
This abstraction isolates a genuinely unstable dependency (the payment provider) while keeping checkout logic independent. When Stripe changes their API or you switch providers, only one class needs to change.
That's a good abstraction. It buys you optionality where you actually need it.
When Abstractions Become Technical Debt
Problems arise when we abstract things that aren't actually volatile. We end up wrapping stable libraries or creating layers that don't add real value. The "clean" layer you added today becomes tomorrow's maintenance burden.
The Repository That Lost Its Way
Most teams start with something reasonable:
public interface IUserRepository
{
Task<IEnumerable<User>> GetAllAsync();
}
But as requirements evolve, so does the interface:
public interface IUserRepository
{
Task<IEnumerable<User>> GetAllAsync();
Task<User?> GetByEmailAsync(string email);
Task<IEnumerable<User>> GetActiveUsersAsync();
Task<IEnumerable<User>> GetUsersByRoleAsync(string role);
Task<IEnumerable<User>> SearchAsync(string keyword, int page, int pageSize);
Task<IEnumerable<User>> GetUsersWithRecentActivityAsync(DateTime since);
// ...and it keeps growing
}
Suddenly, the repository is leaking query logic into its interface. Every new way of fetching users means another method, and your "abstraction" becomes a grab bag of every possible query.
Meanwhile, Entity Framework already gives you all of this through LINQ: strongly typed queries that map directly to SQL. Instead of leveraging that power, you've introduced an indirection layer that hides query performance characteristics and often performs worse. The repository pattern made sense when ORMs were immature. Today, it's often just ceremony.
I've been guilty of this myself. But part of maturing as a developer is recognizing when patterns become anti-patterns. Repositories make sense when they encapsulate complex query logic or provide a unified API over multiple data sources. But you should strive to keep them focused on domain logic. As soon as they explode into a myriad of methods for every possible query, it's a sign that the abstraction has failed.
Service Wrappers: The Good and The Ugly
Not all service wrappers are problematic. Context matters.
✅ Good Example: External API Integration
When integrating with external APIs, a wrapper provides genuine value by centralizing concerns:
public interface IGitHubClient
{
Task<UserDto?> GetUserAsync(string username);
Task<IReadOnlyList<RepoDto>> GetRepositoriesAsync(string username);
}
public class GitHubClient(HttpClient httpClient) : IGitHubClient
{
public Task<UserDto?> GetUserAsync(string username) =>
httpClient.GetFromJsonAsync<UserDto>($"/users/{username}");
public Task<IReadOnlyList<RepoDto>> GetRepositoriesAsync(string username) =>
httpClient.GetFromJsonAsync<IReadOnlyList<RepoDto>>($"/users/{username}/repos");
}
This wrapper isolates GitHub's API details. When authentication changes or endpoints evolve, you update one place. Your business logic never needs to know about HTTP headers, base URLs, or JSON serialization.
❌ Bad Example: Pass-Through Services
The trouble starts when we wrap our own stable services without adding business value:
public class UserService(IUserRepository userRepository)
{
// Just forwarding calls with no added value
public Task<User?> GetByIdAsync(Guid id) => userRepository.GetByIdAsync(id);
public Task<IEnumerable<User>> GetAllAsync() => userRepository.GetAllAsync();
public Task SaveAsync(User user) => userRepository.SaveAsync(user);
}
This UserService
is pure indirection.
All it does is forward calls to the IUserRepository
.
It doesn't enforce business rules, add validation, implement caching, or provide any real functionality.
It's a layer that exists only because "services are good architecture."
As these anemic wrappers multiply, your codebase becomes a maze. Developers waste time navigating layers instead of focusing on where business logic actually lives.
Making Better Decisions
Here's how to think about when abstractions are worth the investment:
Abstract Policies, Not Mechanics
- Policies are decisions that might change: which payment provider to use, how to handle caching, retry strategies for external calls
- Mechanics are stable implementation details: EF Core's LINQ syntax,
HttpClient
configuration, JSON serialization
Abstract policies because they give you flexibility. Don't abstract mechanics, they're already stable APIs that rarely change in breaking ways.
Wait for the Second Implementation
If you only have one implementation, resist the interface urge. A single implementation doesn't justify abstraction, it's premature generalization that adds complexity without benefit.
Consider this evolution:
// Step 1: Start concrete
public class EmailNotifier
{
public async Task SendAsync(string to, string subject, string body)
{
// SMTP implementation
}
}
// Step 2: Need SMS? Now abstract
public interface INotifier
{
Task SendAsync(string to, string subject, string body);
}
public class EmailNotifier : INotifier { /* ... */ }
public class SmsNotifier : INotifier { /* ... */ }
The abstraction emerges naturally when you actually need it. The interface reveals itself through real requirements, not imaginary ones.
Keep Implementations Inside, Abstractions at Boundaries
Inside your application, prefer concrete types.
Use Entity Framework directly, configure HttpClient
as typed clients, work with domain entities.
Only introduce abstractions where your system meets the outside world: external APIs, third-party SDKs, infrastructure services.
That's where change is most likely, and where abstractions earn their keep.
Refactoring Out Bad Abstractions
Regularly review your abstractions with this question: If I removed this abstraction, would the code become simpler or more complex?
If removing an interface or service layer would make the code clearer and more direct, that abstraction is probably costing more than it's worth. Don't be afraid to delete unnecessary layers. Simpler code is often better code.
When you identify problematic abstractions, here's how to safely remove them:
- Identify the real consumers. Who actually needs the abstraction?
- Inline the interface. Replace abstract calls with concrete implementations.
- Delete the wrapper. Remove the unnecessary indirection.
- Simplify the calling code. Take advantage of the concrete API's features.
For example, replacing a repository with direct EF Core usage:
// Before: Hidden behind repository
var users = await _userRepo.GetActiveUsersWithRecentOrders();
// After: Direct, optimized query
var users = await _context.Users
.Where(u => u.IsActive)
.Where(u => u.Orders.Any(o => o.CreatedAt > DateTime.Now.AddDays(-30)))
.Include(u => u.Orders.Take(5))
.ToListAsync();
The concrete version is more explicit about what data it fetches and how, making performance characteristics visible and optimization possible. If you need the same query in multiple places, you could move it into an extension method to make it shareable.
The Bottom Line
Abstractions are powerful tools for managing complexity and change, but they're not free. Each one adds indirection, cognitive overhead, and maintenance burden.
The cleanest architecture isn't the one with the most layers. It's the one where each layer has a clear, justified purpose.
Before adding your next abstraction, ask yourself:
- Am I abstracting a policy or just a mechanic?
- Do I have two implementations, or am I speculating about future needs?
- Will this make my system more adaptable, or just harder to follow?
- If I removed this layer, would the code become simpler?
Remember: abstractions are loans that accrue interest over time. Make sure you're borrowing for the right reasons, not just out of habit.
The goal is to use abstractions intentionally, where they solve real problems and protect against genuine volatility. Build abstractions that earn their keep. Delete the ones that don't.
Thanks for reading.
And stay awesome!