Published on

Modern Isn't a Value. Fit Is.

Authors
  • avatar
    Name
    Omar Elsayed
    Twitter

A few weeks ago, I sat down to wrap an SDK in our codebase. The SDK uses delegates. The codebase that calls into this manager? Almost entirely built on callbacks and delegates too.

So naturally, I reached for async/await. Because it's 2026, right? Why would you build anything with callbacks anymore?

I got halfway through the manager before something started to feel off. I had AsyncStreams feeding into completion handlers feeding back into delegate methods. I had bridges on top of bridges. The "modern" code I was writing was making the rest of the system harder to read, not easier.

And then it clicked: I had built a beautiful Tesla engine and was now trying to bolt it onto a horse carriage. 😂

The Trap of "Modern by Default"

Here's what kept happening in my head:

"Concurrency is the future. Combine is being deprecated. Delegates are old. Use the new stuff."

That's not a wrong instinct. But it's also not a complete one. Because the moment you write a single line of code, you're not writing it in a vacuum — you're writing it inside a system.

And the system has a personality. It has habits. It has patterns it already speaks fluently.

When I picked async/await for that manager, I wasn't asking: does this fit? I was asking: is this modern?

Those are two completely different questions.

Let me paint the real picture. Here's what the calling code looked like:

// Every call site in the codebase looks like this:
someService.fetchSomething { [weak self] result in
    switch result {
    case .success(let data):
        self?.delegate?.didFetch(data)
    case .failure(let error):
        self?.delegate?.didFail(with: error)
    }
}

Callbacks. Delegates. A [weak self] dance everyone in the codebase already knows by heart.

And here's what I was about to ship:

// My "modern" manager:
actor MyShinyManager {
    func doTheThing() async throws -> Result {
        // beautiful, isolated, type-safe, modern
    }
}

// The call site, suddenly:
someService.fetchSomething { [weak self] result in
    Task { [weak self] in
        do {
            let value = try await self?.manager.doTheThing()
            self?.delegate?.didFetch(value)
        } catch {
            self?.delegate?.didFail(with: error)
        }
    }
}

Look at that. Look at it. I added a Task inside a callback inside a delegate. I introduced a new mental model — actors, isolation, structured concurrency — into a file whose entire job is to be a callback.

The manager was clean. The call site was a war crime. The manger didn't fit the system.

The Realization

The lightbulb moment hit me like a truck when I relized that the manager wasn't being used in isolation. It was a participant in a system.

And a participant that doesn't speak the local language forces everyone around it to translate.

Every team member who touches that call site now has to context-switch:

  • "Wait, why is there a Task here?"
  • "Is this await cancellable from the delegate's perspective?"
  • "If the delegate gets deallocated mid-await, what happens?"
  • "Why does this one file look different from the other 200 files in the project?"

I wasn't reducing complexity. I was relocating it — from the manager into every single call site.

Wrong. So wrong.

The Right Question

Here's the question I should have asked from the start:

What's the simplest thing that fits the system I'm working in?

Not "what's newest." Not "what would look impressive in a tech talk." Not "what would I build if this were a greenfield project."

What fits.

If 95% of my codebase speaks delegates and callbacks, then my manager should probably speak delegates and callbacks too — at least at its boundary.

Maybe I use async/await internally for clarity. Maybe I expose both APIs. But the interface needs to match the world it lives in.

// The version I should have shipped:
final class MyManager {
    weak var delegate: MyManagerDelegate?

    func doTheThing() {
        Task {
            // I can still use modern concurrency INSIDE
            // — the outside world doesn't need to know
            do {
                let value = try await internalAsyncWork()
                await MainActor.run {
                    delegate?.manager(self, didFinishWith: value)
                }
            } catch {
                await MainActor.run {
                    delegate?.manager(self, didFailWith: error)
                }
            }
        }
    }
}

Same modern internals. Familiar external shape. Zero translation tax at the call sites.

The Broader Lesson

This isn't just about concurrency. It's about every architectural choice we make.

  • Your team uses MVC and you read about TCA on a Tuesday? Don't refactor on Wednesday.
  • Your codebase is UIKit and SwiftUI just dropped a cool API? Cool. Doesn't mean every screen flips overnight.
  • Your project uses Combine and AsyncSequence looks tempting? Make sure the boundary still feels native.

Modern isn't a value. Fit is a value.

A pattern is only "good" relative to the system it lives in. A perfect actor-based manager in a callback-heavy codebase is worse than a competent delegate-based manager — because the actor-based one taxes everyone who uses it, forever.

When You Should Reach for the New Thing

I'm not saying never modernize. I'm saying: modernize with intent, not by default.

Reach for the new pattern when:

  • → You're starting a new module with no existing callers
  • → The team has agreed to migrate and there's a plan
  • → The new pattern genuinely removes complexity, not just relocates it
  • → You can introduce it at a boundary that hides the seam

Skip the new pattern when:

  • → Every call site would need a Task { } wrapper or a Combine bridge
  • → You're the only one on the team who's comfortable with it
  • → "It's more modern" is the strongest argument you have

Conclusion

I rewrote the manager. The internals still use structured concurrency where it makes sense — but the API it exposes looks and feels like every other manager in the codebase. The call sites went back to being boring.

And boring, in this context, is a feature.

The next time you reach for the shiniest tool in your toolbox, take a moment to look at the house you're building it into.

A diamond-tipped drill is incredible. But if every other tool on the job site is a hammer, sometimes the right move is to pick up the hammer.

You're not writing code in isolation — you're contributing to a system. And the best code isn't the most modern. It's the code that the next person on your team reads without flinching.

subscribe for more like this

Subscribe for more