
Getting Started: C#
Get started with programming using the C# programming language
You’ve seen SOLID mentioned in talks and code reviews. This post shows what each letter looks like in real C# and when it helps. No fluff, just small examples you can paste into a project.
One reason to change. A type should own one slice of behavior. When a class mixes I/O, validation, and orchestration, every change touches it and tests get noisy.
Smell: a class that sends emails, saves to the database, and logs.
// ❌ Violates SRP
public sealed class OrderService
{
public void PlaceOrder(Order order)
{
// Validate
if (order.Lines.Count == 0)
throw new InvalidOperationException("Empty order");
// Save
using var db = new SqlConnection("...");
db.Execute("INSERT ...", order);
}
}
Better: split responsibilities. Orchestration stays in OrderService
, persistence and email move to small, focused types.
public interface IOrderRepository
{
Task SaveAsync(Order order, CancellationToken ct = default);
}
public interface IEmailSender
{
Task SendAsync(string to, string subject, string body, CancellationToken ct = default);
}
public sealed class OrderService(IOrderRepository repo, IEmailSender email)
{
public async Task PlaceAsync(Order order, CancellationToken ct = default)
{
if (order.Lines.Count == 0)
throw new InvalidOperationException("Empty order");
await repo.SaveAsync(order, ct);
await email.SendAsync(order.CustomerEmail, "Thanks", "We got your order.", ct);
}
}
Now each concern changes in isolation and testing is straightforward.
Open for extension, closed for modification. When a rule changes, you add a new type rather than edit a big switch
/if
chain.
Smell: a calculator that branches on type
with if/else
across the codebase.
// ❌ Violates OCP
public sealed class ShippingCost
{
public decimal Calculate(string countryCode, decimal weightKg) => countryCode switch
{
"SE" => 80m + weightKg * 5m,
"DE" => 60m + weightKg * 4m,
"US" => 120m + weightKg * 7m,
_ => throw new NotSupportedException(countryCode),
};
}
Better: a small strategy interface, new rules are new classes.
public interface IShippingRule
{
bool Matches(string countryCode);
decimal Cost(decimal weightKg);
}
public sealed class SwedenRule : IShippingRule
{
public bool Matches(string c) => c == "SE";
public decimal Cost(decimal w) => 80m + w * 5m;
}
public sealed class GermanyRule : IShippingRule
{
public bool Matches(string c) => c == "DE";
public decimal Cost(decimal w) => 60m + w * 4m;
}
public sealed class ShippingCalculator(IEnumerable<IShippingRule> rules)
{
public decimal Calculate(string countryCode, decimal weightKg)
{
var rule = rules.FirstOrDefault(r => r.Matches(countryCode))
?? throw new NotSupportedException(countryCode);
return rule.Cost(weightKg);
}
}
Wire new countries by adding classes, not editing ShippingCalculator
.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped<IShippingRule, SwedenRule>();
builder.Services.AddScoped<IShippingRule, GermanyRule>();
builder.Services.AddScoped<ShippingCalculator>();
Subtypes must honor the contract of their base. If a base says “you can call this method,” a subtype shouldn’t throw or no-op.
Smell: deriving from a type just to reuse a few members, then throwing in methods you can’t support.
// ❌ Violates LSP
public class FileStorage
{
public virtual Task WriteAsync(string path, byte[] data) => File.WriteAllBytesAsync(path, data);
public virtual Task<byte[]> ReadAsync(string path) => File.ReadAllBytesAsync(path);
}
public sealed class ReadOnlyStorage : FileStorage
{
public override Task WriteAsync(string path, byte[] data) =>
throw new NotSupportedException("Read-only storage");
}
Any caller using FileStorage
can validly call WriteAsync
. Substituting ReadOnlyStorage
breaks that promise.
Better: split capabilities and implement only what you can fulfill.
public interface IReadableStorage
{
Task<byte[]> ReadAsync(string path);
}
public interface IWritableStorage : IReadableStorage
{
Task WriteAsync(string path, byte[] data);
}
public sealed class LocalStorage : IWritableStorage
{
public Task<byte[]> ReadAsync(string path) => File.ReadAllBytesAsync(path);
public Task WriteAsync(string path, byte[] data) => File.WriteAllBytesAsync(path, data);
}
public sealed class ArchiveStorage : IReadableStorage // read-only
{
public Task<byte[]> ReadAsync(string path) => File.ReadAllBytesAsync(path);
}
Callers choose the right abstraction up front and never hit a surprise NotSupportedException
.
Small, focused interfaces. Clients shouldn’t depend on methods they don’t use.
Smell: “God interfaces” that force implementers to stub or throw.
// ❌ Violates ISP
public interface IOrderProcessor
{
Task PlaceAsync(Order order);
Task CancelAsync(Guid orderId);
Task RefundAsync(Guid orderId);
Task PrintPickingListAsync(Guid orderId);
}
public sealed class ThirdPartyFulfillment : IOrderProcessor
{
public Task PlaceAsync(Order o) => /* call API */ Task.CompletedTask;
public Task CancelAsync(Guid id) => /* call API */ Task.CompletedTask;
public Task RefundAsync(Guid id) => /* not supported */ throw new NotSupportedException();
public Task PrintPickingListAsync(Guid id) => /* irrelevant */ Task.CompletedTask;
}
Better: split by use-case and depend on the minimum you need.
public interface IOrderPlacement
{
Task PlaceAsync(Order order);
}
public interface IOrderCancellation
{
Task CancelAsync(Guid orderId);
}
public interface IOrderRefunds
{
Task RefundAsync(Guid orderId);
}
public sealed class ThirdPartyPlacement : IOrderPlacement, IOrderCancellation
{
public Task PlaceAsync(Order o) => Task.CompletedTask;
public Task CancelAsync(Guid id) => Task.CompletedTask;
}
Consumers don’t see methods they’ll never call. Implementers don’t need fake bodies.
High-level code depends on abstractions, not concretes. This keeps orchestration free from storage, transport, or library choices.
Smell: a service new’s up its own dependencies, making tests and changes hard.
// ❌ Violates DIP
public sealed class InvoiceService
{
public Task<Invoice> GenerateAsync(Guid orderId)
{
var repo = new SqlOrderRepository("Server=..."); // hard dependency
var order = repo.Get(orderId);
// ...
return Task.FromResult(new Invoice(orderId, DateTimeOffset.UtcNow));
}
}
Better: depend on interfaces and inject implementations via the container.
public interface IOrderReader
{
Task<Order> GetAsync(Guid id, CancellationToken ct = default);
}
public interface ITimeProvider
{
DateTimeOffset Now();
}
public sealed class SystemTime : ITimeProvider
{
public DateTimeOffset Now() => DateTimeOffset.UtcNow;
}
public sealed class InvoiceService(IOrderReader orders, ITimeProvider clock)
{
public async Task<Invoice> GenerateAsync(Guid orderId, CancellationToken ct = default)
{
var order = await orders.GetAsync(orderId, ct);
return new Invoice(order.Id, clock.Now());
}
}
Register with the built-in DI and keep your endpoints thin.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped<IOrderReader, SqlOrderRepository>();
builder.Services.AddSingleton<ITimeProvider, SystemTime>();
builder.Services.AddScoped<InvoiceService>();
var app = builder.Build();
app.MapPost("/invoices/{orderId:guid}", async (Guid orderId, InvoiceService svc, CancellationToken ct) =>
{
var invoice = await svc.GenerateAsync(orderId, ct);
return Results.Ok(invoice);
});
app.Run();
Now tests can pass a fake IOrderReader
and a fixed-time clock without booting a database or web host.
These are cues to apply one of the letters, not rules to follow blindly. Prefer small steps: extract an interface, move a method, split a file.
Minimal fake clock (useful across SRP/DIP):
public sealed class FakeTime(DateTimeOffset now) : ITimeProvider
{
public DateTimeOffset Value { get; set; } = now;
public DateTimeOffset Now() => Value;
}
Repository example (read side):
public sealed class SqlOrderRepository(IConfiguration cfg) : IOrderReader
{
private readonly string _cs = cfg.GetConnectionString("Default")
?? throw new InvalidOperationException("Missing connection string");
public async Task<Order> GetAsync(Guid id, CancellationToken ct = default)
{
await using var con = new SqlConnection(_cs);
// query with your library of choice
return await Task.FromResult(new Order(id));
}
}
SOLID isn’t a checklist, it’s a set of small moves that reduce friction when you change code. Reach for it when you spot these signs:
switch
in three places.Pick one seam that hurts today and make one improvement: split a responsibility, extract a strategy, trim an interface, or inject an abstraction. Ship the change. If the codebase moves faster next week, you’re on the right track.
© 2025 Dometrain. All rights reserved.