~/forbannet/blog~encapsulation-is-not-a-silve…
now compiling: io_uring branch, --releaselatest push: e8af13c → main · 12 min agoreading: "What Every Programmer Should Know About Memory" — Drepper, 2007currently playing with: ftrace + perf for syscall latencyopen PRs: 3 · issues triaged today: 14now compiling: io_uring branch, --releaselatest push: e8af13c → main · 12 min agoreading: "What Every Programmer Should Know About Memory" — Drepper, 2007currently playing with: ftrace + perf for syscall latencyopen PRs: 3 · issues triaged today: 14
Techie#5782
Techie#5782guest
//.post.07.programmingpublished
DETOURS.DLL / 0x4C3F / ws2_32::connect()  ws2_32::send()  ws2_32::recv()
55                 PUSH    EBP
8B EC              MOV     EBP, ESP
83 EC 14           SUB     ESP, 14h
E8 25 00 00 00     CALL    DetourTransactionBegin
0F B6 45 08        MOVZX   EAX, BYTE [EBP+8]
KERNELRIOT / reader::post / markdown::render / prism::pending
55                 PUSH    EBP
8B EC              MOV     EBP, ESP
post.0x07programming.reader
programmingdevmadnessoopriests

Encapsulation is not a Silver Bullet

Pretty self explanatory, yeah?

/blog/encapsulation-is-not-a-silver-bullet

Encapsulation Isn't Always the Answer

Encapsulation is taught as principle. It functions as habit. The distinction matters because principles get evaluated, habits get repeated. Most codebases do not suffer from too little encapsulation. They suffer from encapsulation applied where it creates more structure than the problem requires, which then becomes the problem itself.

The argument here is not against encapsulation. It is against encapsulation as default. The default should be clarity, and clarity sometimes means exposing exactly what is happening, to whom, with what.

Data Is Not Behavior

OOP fuses data and behavior into objects. The fusion is presented as natural: a Payment knows how to validate itself, a User knows how to authenticate. But payments do not know anything. They are amounts, currencies, and methods. Validation is a question you ask about a payment, not a capability the payment possesses.

The confusion has consequences. When data owns its behavior, every new operation on that data must either live inside the struct or reach into it through an interface. Both options create coupling that did not need to exist.

Separating them is not a radical act. It is the older and simpler design:

struct Payment {
    amount: f64,
    currency: Currency,
    method: PaymentMethod,
}

fn validate(p: &Payment) -> Result<(), ValidationError> {
    // the payment is examined, not consulted
}

fn process(p: &Payment) -> Result<TransactionId, PaymentError> {
    validate(p)?;
    // processing operates on data it can see entirely
}

The payment carries no logic. The functions carry no state. Testing either in isolation is trivial because neither depends on the other's internals. The same structure in C# makes the point just as clearly:

public readonly record struct Payment(
    decimal Amount,
    Currency Currency,
    PaymentMethod Method);

public static class Payments
{
    public static Result<Unit, ValidationError> Validate(in Payment p)
    {
        // pure function, payment is immutable, nothing hides
    }

    public static Result<TransactionId, PaymentError> Process(in Payment p)
    {
        var v = Validate(p);
        if (v.IsErr) return v.Err;
        // explicit pipeline, zero internal state
    }
}

The readonly record struct and in parameter are not decoration. The struct is stack-allocated, the in passes by readonly reference. No heap. No mutation. The data is inert and the logic is visible. This is not functional programming evangelism. It is the recognition that most domain data does not need to carry behavior, and attaching behavior to it costs you the ability to see your own system.

The Manager Problem

When encapsulation is the default, every cluster of related operations needs a home. That home becomes a class. That class needs a name. The name becomes UserManager, OrderService, PaymentHandler. These are not domain concepts. They are filing cabinets.

A UserManager that creates users, deletes users, resets passwords, sends emails, and generates reports is not a coherent abstraction. It is a namespace with a constructor. Every dependency it holds exists because some method somewhere inside needs it, but no single method needs all of them. The object carries weight it never uses in any given call path.

// this is not an abstraction, it is a bag
struct UserManager {
    db: Database,
    auth: AuthService,
    email: EmailClient,
    cache: Cache,
}

Every field is a dependency. Every method uses a subset. The struct exists because OOP demands a location for functions, and "user-related" felt like a category. It was not. It was four categories wearing one name.

Modules solve this without the ceremony:

mod users {
    pub fn create(db: &Database, data: UserData) -> Result<User, Error> { /* ... */ }
    pub fn delete(db: &Database, id: UserId) -> Result<(), Error> { /* ... */ }
}

mod auth {
    pub fn reset_password(db: &Database, auth: &AuthService, id: UserId) -> Result<(), Error> { /* ... */ }
}

mod comms {
    pub fn send_welcome(email: &EmailClient, user: &User) -> Result<(), Error> { /* ... */ }
}

Each function declares exactly what it needs. Nothing is constructed that will not be used. The dependency graph is in the signature, not in a constructor you have to read to understand.

C# does not have Rust's module system, but static classes with explicit parameters achieve the same structural honesty:

public static class Users
{
    public static Result<User, Error> Create(IDatabase db, UserData data) { /* ... */ }
    public static Result<Unit, Error> Delete(IDatabase db, UserId id) { /* ... */ }
}

public static class Auth
{
    public static Result<Unit, Error> ResetPassword(IDatabase db, IAuthService auth, UserId id) { /* ... */ }
}

No constructor. No fields. No hidden graph of dependencies initialized at startup and dragged through every call. The function signature is the contract. You read it, you know everything.

Pattern Matching vs. Polymorphism

Polymorphism through virtual dispatch solves a real problem: when the set of types is open and new variants arrive at runtime, you need dynamic dispatch. Plugin systems, driver models, extension points. These are legitimate uses.

Most code is not a plugin system. Most code operates on a closed set of known variants. Using trait objects or interfaces for a set of shapes you fully control at compile time is paying a runtime cost for flexibility you will never exercise.

enum Shape {
    Circle { center: Point, radius: f64 },
    Rect { origin: Point, w: f64, h: f64 },
    Tri { pts: [Point; 3] },
}

fn area(s: &Shape) -> f64 {
    match s {
        Shape::Circle { radius, .. } => std::f64::consts::PI * radius * radius,
        Shape::Rect { w, h, .. } => w * h,
        Shape::Tri { pts } => tri_area(pts),
    }
}

The match is exhaustive. The compiler enforces it. If you add a variant and forget to handle it, the code does not compile. Trait objects give you no such guarantee. You discover the missing implementation at runtime, or you don't.

The cost difference is also concrete. An enum is a tagged union on the stack. A Box<dyn Shape> is a heap allocation plus a vtable indirection per call. For a hot loop processing thousands of shapes, that difference is not theoretical. It is the difference between data that fits in cache and data that chases pointers.

Hidden State Is Hidden Cost

A method on a stateful object operates in a context you cannot see from the call site. You call validator.validate(payment) and the result depends on the payment you passed, the config loaded at construction, the rate limiter's internal counter, and the blacklist's last refresh time. Four inputs. One visible.

fn validate(
    p: &Payment,
    cfg: &Config,
    limiter: &RateLimiter,
    blacklist: &Blacklist,
) -> Result<(), ValidationError> {
    if p.amount < cfg.min || p.amount > cfg.max {
        return Err(ValidationError::Amount);
    }
    if !cfg.methods.contains(&p.method) {
        return Err(ValidationError::Method);
    }
    if limiter.is_limited(p.user_id) {
        return Err(ValidationError::RateLimit);
    }
    if blacklist.contains(p.user_id) {
        return Err(ValidationError::Blocked);
    }
    Ok(())
}

Four parameters instead of one self. That is not clutter. That is the actual dependency surface, made legible. The previous version had the same four dependencies. It just hid three of them behind a constructor you had to go read separately.

Testing follows directly. To test the amount check, pass a config with specific bounds and a payment with a specific amount. You do not need to construct a PaymentValidator with a mock rate limiter and a mock blacklist just to test arithmetic. The function's signature tells you what it touches. The object's signature told you nothing.

Composition Over Inheritance

Inheritance creates a permanent relationship between types. A DatabaseLogger that extends FilteredLogger that extends Logger is three types coupled vertically. Changing the base changes everything below it. The hierarchy is a bet that the taxonomy is correct and will remain correct. Taxonomies in software are almost never correct and almost never remain so.

Composition makes the same system out of independent parts:

struct Logger<W: LogWriter, F: LogFormatter> {
    writer: W,
    level: LogLevel,
    fmt: F,
}

impl<W: LogWriter, F: LogFormatter> Logger<W, F> {
    fn log(&mut self, msg: &str, lvl: LogLevel) -> Result<(), Error> {
        if lvl <= self.level {
            let entry = self.fmt.format(msg, lvl);
            self.writer.write(&entry)?;
        }
        Ok(())
    }
}

W and F are generic parameters, not parent classes. They are resolved at compile time. There is no vtable. There is no heap allocation for the trait object. The compiler monomorphizes Logger<FileWriter, JsonFormatter> into a concrete type with zero abstraction overhead. You can swap FileWriter for DatabaseWriter without touching Logger itself because Logger never knew which writer it had. It only knew the writer could write.

The C# equivalent loses monomorphization but keeps the structural benefit:

public sealed class Logger<TWriter, TFormatter>
    where TWriter : ILogWriter
    where TFormatter : ILogFormatter
{
    readonly TWriter _w;
    readonly TFormatter _f;
    readonly LogLevel _level;

    public Logger(TWriter w, TFormatter f, LogLevel level)
    {
        _w = w; _f = f; _level = level;
    }

    public Result<Unit, Error> Log(string msg, LogLevel lvl)
    {
        if (lvl > _level) return Ok();
        var entry = _f.Format(msg, lvl);
        return _w.Write(entry);
    }
}

No base class. No override chain. No fragile hierarchy. Parts are assembled, not inherited. When a part changes, only that part changes.

The Point

Encapsulation is a tool that solves specific problems: protecting invariants, hiding implementation details that genuinely need hiding, providing a stable interface over unstable internals. These are real needs in real systems.

The mistake is treating it as a baseline. When encapsulation is the default, you end up encapsulating things that had no reason to be hidden, constructing objects that had no reason to exist, and building hierarchies that describe your naming conventions more than your domain. The cost is not dramatic. It is cumulative. Every unnecessary indirection is a sentence the next developer has to read without learning anything. Enough of those sentences and the codebase becomes a book that is long but says little.

Start with data. Add behavior where behavior is needed. Make dependencies visible. Let the compiler enforce what the compiler can enforce. Reach for encapsulation when there is something specific to protect. Not before.

/comments

comments (0)

Markdown supported, fenced code encouraged.

no comments yet — be the first.
// add to the thread
TE
posting as Techie#5782 guest
be excellent. ⇧⏎ for newline.