The Tyranny of General Purpose: Why C# Makes Bad Programmers Worse
“When you give everyone a loaded gun, you shouldn’t be surprised when they shoot themselves.”
The Golden Age We Forgot (And Still Use)
Let me take you back to 1998.
You’re building a business application. You write your UI and business logic in Visual Basic 6. When you need performance or system access, you call into COM objects written in C++ or Delphi. You don’t mix the two—VB is for orchestration, COM is for implementation.
Was it perfect? Hell no. COM was a nightmare of reference counting and arcane registry magic. VB6 had On Error Resume Next (enough said). The tooling was primitive by today’s standards.
But here’s what it got profoundly right:
There was a clear boundary between orchestration and implementation.
Junior developers wrote VB. They called database.ExecuteQuery() and email.Send(). They didn’t write the database layer or the email library. They composed existing components into business workflows.
Senior developers and specialists wrote COM. They understood threading, memory management, and performance. They built the components that juniors would use.
Different skill levels worked at different layers, and the technology enforced it.
Wait, This Sounds Familiar…
Actually, we still do this today. Just with different names.
Modern equivalent: Python + C++
Data scientists use Python. They write:
import numpy as np
import pandas as pd
df = pd.read_csv("data.csv")
result = np.mean(df['values']) They’re not writing NumPy or Pandas. Those are C/C++ libraries with Python wrappers. The data scientist orchestrates existing components. They don’t implement the matrix multiplication or CSV parser.
Library authors write the C++ core. They understand SIMD, cache optimization, and memory layouts. They build the components that data scientists use.
The same pattern:
- Python for orchestration (high-level, accessible)
- C++ for implementation (performance-critical, specialist)
- Clear boundary between the two
This works! NumPy powers trillions of dollars of value. TensorFlow runs the world’s AI. Django serves millions of websites.
But it has costs:
- FFI overhead (marshaling data between Python and C++)
- GIL contention (Python’s Global Interpreter Lock)
- Two build systems, two debuggers, two profilers
- Deployment complexity (Python dependencies + native libraries)
The separation is valuable, but the boundary is expensive.
So what went wrong with C#?
Then C# Tried to Be Everything
C# came along and said: “Why have two languages? Let’s make ONE language that can do both!”
You can write high-level business logic in C#. You can also write performance-critical code in C#. You can define interfaces, create generic libraries, build frameworks—all in the same language.
This sounds great until you realize: it gave everyone the power to create abstractions, and we’re surprised when they misuse it.
What Went Wrong: The C# Revolution
C# was supposed to be the best of both worlds: VB’s ease of use with C++‘s power. And in many ways, it succeeded. But it made one catastrophic decision:
It gave everyone the power to create abstractions.
Suddenly, every developer could:
- Define interfaces (
interface IOrderService) - Create generic types (
Repository<T>) - Build dependency injection frameworks
- Write “enterprise patterns”
The language made abstraction easy. And professional. And expected.
The result? Let me show you some real code from a production C# codebase (slightly anonymized):
public interface IOrderProcessor
{
Task<ProcessOrderResult> ProcessOrder(ProcessOrderRequest request);
}
public class ProcessOrderRequest
{
public Guid OrderId { get; set; }
public ProcessingOptions Options { get; set; }
}
public class ProcessOrderResult
{
public bool Success { get; set; }
public string? ErrorMessage { get; set; }
public Order? ProcessedOrder { get; set; }
}
public class OrderProcessor : IOrderProcessor
{
private readonly ILogger<OrderProcessor> _logger;
private readonly IOrderRepository _repository;
private readonly IPaymentService _paymentService;
private readonly IEmailService _emailService;
private readonly IOptions<OrderProcessingSettings> _settings;
public OrderProcessor(
ILogger<OrderProcessor> logger,
IOrderRepository repository,
IPaymentService paymentService,
IEmailService emailService,
IOptions<OrderProcessingSettings> settings)
{
_logger = logger;
_repository = repository;
_paymentService = paymentService;
_emailService = emailService;
_settings = settings;
}
public async Task<ProcessOrderResult> ProcessOrder(ProcessOrderRequest request)
{
try
{
_logger.LogInformation($"Processing order {request.OrderId}");
var order = await _repository.GetById(request.OrderId);
if (order == null)
{
return new ProcessOrderResult
{
Success = false,
ErrorMessage = "Order not found"
};
}
var paymentResult = await _paymentService.ProcessPayment(
new ProcessPaymentRequest { Amount = order.Total });
if (!paymentResult.Success)
{
return new ProcessOrderResult
{
Success = false,
ErrorMessage = paymentResult.ErrorMessage
};
}
await _emailService.SendOrderConfirmation(order.CustomerEmail);
return new ProcessOrderResult
{
Success = true,
ProcessedOrder = order
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing order");
return new ProcessOrderResult
{
Success = false,
ErrorMessage = ex.Message
};
}
}
}
// And of course, the DI registration:
services.AddScoped<IOrderProcessor, OrderProcessor>();
services.AddScoped<IOrderRepository, OrderRepository>();
services.AddScoped<IPaymentService, PaymentService>();
services.AddScoped<IEmailService, EmailService>(); 67 lines of code to do something that should take 10.
Look at what happened here:
- An interface that only has one implementation (why?)
- Request/response wrapper classes (why?)
- A constructor with 5 dependencies (why?)
- Try/catch that turns exceptions into
Success: false(why?) - Manual DI registration (why?)
This code was written by a mid-level developer who thought they were being “professional.” They created interfaces because “that’s what you do.” They used dependency injection because “that’s best practice.” They made generic wrappers because “that’s enterprise architecture.”
C# didn’t just allow this. It encouraged it.
The Abstraction Gun
Here’s the uncomfortable truth: most developers shouldn’t be creating abstractions.
That’s not elitist. It’s specialization. We don’t expect everyone to:
- Design databases (that’s a DBA’s job)
- Configure networks (that’s a network engineer’s job)
- Tune performance (that’s a performance specialist’s job)
So why do we expect everyone to design abstractions?
Creating good abstractions requires:
- Deep domain understanding
- Experience with multiple implementations
- Ability to predict future requirements
- Understanding of coupling and cohesion
Most developers don’t have this. And that’s okay. They’re good at other things:
- Understanding business requirements
- Implementing algorithms
- Orchestrating workflows
- Debugging issues
But C# gives everyone a loaded gun (the power to abstract), tells them it’s “professional” to use it, and then we act surprised when codebases become unmaintainable.
The interface above (IOrderProcessor) was created “for testability” by someone who never wrote a test. The generic repository was created “for flexibility” when there was only ever one database. The dependency injection was added “for loose coupling” by someone who didn’t understand coupling.
Premature abstraction is the root of all evil in modern software.
And general-purpose languages like C# make it trivially easy to commit this sin.
Recognizing the Problem: A Thought Experiment
While designing Koru, I explored a thought experiment: what if we kept C# for structure but created a simpler language for behavior? Call it C= (C minus minus).
The idea was simple:
Separate structure (C#) from behavior (C=).
In this thought experiment:
- Interfaces would be defined in C#
- Implementation would be written in C=
- No explicit DI, constructors, or boilerplate
- C= files would just implement behavior
Here’s how it might look:
// C# defines the interface
public interface IOrderHandler {
Task Submit(Order order);
Task<Order?> Get();
} // C= implements it (pseudo-syntax)
implements IOrderHandler
Submit(order) {
logger.LogInformation($"Submitting order {order.Id}");
return repository.Save(order);
}
Get() {
return repository.Get(this.Id);
} The C= compiler would emit:
public class OrderHandler : IOrderHandler {
// Constructor with inferred dependencies
// Concrete implementations
} This was the right instinct. Separate the layers. Let one language handle structure, another handle behavior.
But it has a fundamental problem: you still need a separate language for contracts. Someone still has to define those interfaces in C#. You’d have two languages to learn, two compilers, two ecosystems.
This thought experiment helped clarify what was really needed: a language where the contracts themselves don’t require a separate host.
How Koru Delivers the Vision
Koru takes a different approach: events are the interface. No host language needed for contracts.
The event declaration is the contract:
~event processOrder { order_id: OrderId }
| success { order: Order }
| not_found {}
| payment_failed { reason: []const u8 }
| email_failed { reason: []const u8 } This is better than requiring a separate interface language because:
- All possible outcomes are explicit - not just success
- No wrapper classes needed - branches carry typed data
- Events ARE the contract - no C# interface files required
- Self-documenting - you see what can happen at a glance
Now, Koru does compile to Zig, and you can drop into Zig inside procs when needed. But the key difference is:
- C= would need C# to define interfaces (contracts in one language, implementation in another)
- Koru defines contracts as events (contracts and implementation in the same language ecosystem)
The event is the interface. The proc uses Zig for implementation details. But you don’t need a separate language to define structure.
Now let’s look at the four levels:
Level 1: Flow Orchestration (Anyone Can Do This)
Most developers will write code like this:
~order.process(order_id: id)
| success order |> payment.charge(amount: order.order.total)
| charged tx |> email.sendConfirmation(to: order.order.customer_email)
| sent |> log.info(msg: "Order complete")
| logged |> _
| email_failed e |> log.warn(msg: e.reason)
| logged |> _
| payment_failed p |> log.error(msg: p.reason)
| logged |> _
| not_found |> log.error(msg: "Order not found")
| logged |> _ This is easier to read than Python. It’s just composition. Anyone can do this.
No DI containers. No interfaces. No constructors. Just: “call this event, handle the branches, chain the next event.”
Level 2: Event Design (Mid-Level Engineers)
Designing good events requires domain understanding:
~event processOrder { order_id: OrderId }
| success { order: Order }
| not_found {}
| payment_failed { reason: []const u8 }
| email_failed { reason: []const u8 } Questions you have to answer:
- What are all the possible outcomes? (requires domain knowledge)
- What data does each branch need? (requires understanding callers)
- Should some branches be optional? (requires API design thinking)
This is harder than Level 1, and that’s good. Not everyone should be designing events. But those who do create clean, usable contracts.
Level 3: Proc Implementation (Junior/AI/Specialists)
Implementing the proc is mechanical:
~proc processOrder {
const order = repository.get(order_id) orelse {
return .{ .not_found = .{} };
};
const payment = paymentService.charge(order.total) catch |err| {
return .{ .payment_failed = .{ .reason = @errorName(err) } };
};
emailService.send(order.customer_email) catch |err| {
return .{ .email_failed = .{ .reason = @errorName(err) } };
};
return .{ .success = .{ .order = order } };
} This could be written by:
- A junior developer following the contract
- An AI given the event signature
- An outsourced team
- A domain specialist (payments, email, etc.)
The event signature tells you exactly what to implement. No decisions about abstraction, just implementation.
Level 4: Library Design (Senior Engineers)
Senior engineers design reusable event libraries:
// HTTP library
~event http.get { url: []const u8 }
| ok { body: []const u8, status: u16 }
| not_found {}
| server_error { code: u16, msg: []const u8 }
| network_error { reason: []const u8 }
// Database library
~event db.query { sql: []const u8, params: []const Param }
| rows { data: []Row }
| empty {}
| syntax_error { msg: []const u8, position: usize }
| connection_error { reason: []const u8 }
// Cache library
~event cache.get { key: []const u8 }
| hit { value: []const u8 }
| miss {}
| expired {} This requires:
- Understanding the domain deeply
- Anticipating all failure modes
- Designing clean, minimal APIs
- Balancing specificity vs. generality
Most developers will never do this. They’ll use these libraries. And that’s perfect.
The Separation is Natural, Not Enforced
Here’s the beautiful part: Koru doesn’t enforce these levels through language restrictions. There’s no “junior mode” vs “senior mode.”
Instead, the structure of the language makes good boundaries natural:
Flow orchestration is easier than event design
- Composing events is simpler than declaring them
- Most code will be Level 1 (composition)
Event design is harder than proc implementation
- Thinking about all branches requires domain expertise
- Implementation is mechanical once the event exists
Library design is hardest
- Requires deep understanding of multiple use cases
- Naturally becomes the domain of specialists
Bad abstractions are harder to create in Koru than good workflows.
Compare to C#, where:
- Creating an interface is trivial (
interface IFoo) - Creating generic types is easy (
class Repository<T>) - Adding DI is standard (
services.AddScoped<>)
C# makes abstraction the easy path. Koru makes composition the easy path.
The Comparison
Let’s see the same “process order” logic in all three approaches:
C# (Enterprise Boilerplate)
// 67 lines shown earlier
// Interfaces, DI, try/catch, wrapper classes, manual registration C= Thought Experiment (Two Languages)
// C# interface (separate file, separate language)
public interface IOrderProcessor {
Task<OrderResult> Process(Guid orderId);
}
// C= implementation (different language)
implements IOrderProcessor
Process(orderId) {
var order = repository.Get(orderId);
if (order == null) return OrderResult.NotFound();
var payment = paymentService.Charge(order.Total);
if (!payment.Success) return OrderResult.PaymentFailed();
emailService.SendConfirmation(order.Email);
return OrderResult.Success(order);
} Better than C#, but requires two languages: C# for contracts, C= for implementation.
Koru (Events as Interfaces)
// Event declaration (the contract) - pure Koru
~event processOrder { order_id: OrderId }
| success { order: Order }
| not_found {}
| payment_failed { reason: []const u8 }
| email_failed { reason: []const u8 }
// Implementation (could be separate file/developer) - Koru + Zig
~proc processOrder {
const order = repository.get(order_id) orelse {
return .{ .not_found = .{} };
};
const payment = paymentService.charge(order.total) catch |err| {
return .{ .payment_failed = .{ .reason = @errorName(err) } };
};
emailService.send(order.customer_email) catch |err| {
return .{ .email_failed = .{ .reason = @errorName(err) } };
};
return .{ .success = .{ .order = order } };
}
// Usage (anyone can write this) - pure Koru
~order.process(order_id: current_order_id)
| success o |> log.info(msg: "Order processed")
| logged |> _
| not_found |> log.error(msg: "Order not found")
| logged |> _
| payment_failed p |> log.error(msg: p.reason)
| logged |> _
| email_failed e |> log.warn(msg: e.reason)
| logged |> _ Events are the interface. Koru compiles to Zig, but you don’t need Zig to define contracts—just to implement the low-level details when needed. One language ecosystem, clear separation of concerns.
Why This Changes Everything
VB6/COM had enforced separation through different technologies (VB vs COM). That was clunky.
Python/C++ has enforced separation through different languages (Python orchestration, C++ implementation). This works, but requires managing two completely separate ecosystems.
The C= thought experiment tried separation through different languages for contracts vs implementation (C# for interfaces, C= for behavior). That would have been complex—still two languages.
Koru achieves separation through language structure within one ecosystem:
- Events are self-documenting contracts - no separate interface files
- Procs are isolated implementations - can be tested independently
- Flows are readable orchestration - easier than Python for composition
- The monadic structure prevents bad abstractions - see our Free Monad post
- Zero-cost abstraction - no FFI overhead, no language boundary, compiles to efficient Zig
Unlike Python/C++, where crossing the language boundary has real cost (marshaling data, FFI calls, GIL contention), Koru’s separation is purely conceptual. Events compile directly to Zig with no runtime overhead. You get the organizational benefits of separation without the performance costs of multiple languages.
Most importantly: the majority of code (flows) is accessible to everyone, while abstraction design naturally falls to specialists.
The Controversial Truth
Not everyone should design abstractions. And that’s okay.
It’s not elitist to say that some skills are specialized. We don’t expect every developer to:
- Design compilers
- Write operating systems
- Build databases
- Create frameworks
Why should we expect everyone to design abstractions?
Good tools enforce good boundaries. Not through restrictions, but through making the right thing easy and the wrong thing hard.
C# made abstraction easy. The result: interface hell, generic explosion, DI madness.
Koru makes composition easy. The result will be: readable flows, clean contracts, focused implementations.
The Path Forward
If you’re a C# developer reading this and feeling defensive, I get it. You’ve been told that interfaces, DI, and generic types are “professional” and “best practice.”
But ask yourself:
- How many interfaces in your codebase have only one implementation?
- How much DI boilerplate exists just to “inject” things?
- How many generic wrappers add zero value?
- How often do you create abstractions that are never reused?
You’re not a bad programmer. You’re using a tool that encourages bad habits.
Koru offers a different path:
- Compose workflows (easy, accessible)
- Implement contracts (mechanical, clear)
- Design events (thoughtful, specialized)
Most developers will work at Level 1 and 3. Some will work at Level 2. A few will work at Level 4.
And that’s the way it should be.
Further Reading
Want to understand the theoretical foundations that make this work?
- Events Are Monads: The Free Monad at the Heart of Koru - The deep theory
- Standing on Shoulders: Libero, Pieter Hintjens, and Event Continuation - The historical lineage
- Branches, Not Errors - How Koru handles all outcomes equally
Disagree? Think I’m full of it? Want to defend C#‘s honor? I’d love to discuss! Join us on GitHub Discussions or Discord.
Yes, this post is intentionally provocative. But the arguments are serious, the code is real, and the solution is working. Come prove me wrong—or join us in building something better.