Polymorphism isn't evil. Your API might be.
TL;DR: Polymorphism can be a great internal design and still be the wrong abstraction to expose through an API. We built a generic, extensible registration system that I still think was well designed—but we made consumers understand too much of its internal model. Rust made the implementation especially expensive to maintain, and startup priorities made the missing consumer-facing layer easy to leave unfinished. The lesson wasn’t “don’t use polymorphism.” It was: your implementation needs to understand its own complexity; your consumers probably shouldn’t have to.
A few years ago, I designed a registration system for integrating with third-party SMS registration providers. The immediate requirement involved 10-digit phone numbers used for application-to-person messaging in the United States, commonly called 10DLC (10-Digit Long Code).
Businesses using these numbers for application-generated SMS generally need to register information about who they are and what kinds of messages they’re sending. One important part of that ecosystem is The Campaign Registry (TCR), which acts as a central registry for 10DLC brands and messaging campaigns.
Our immediate need was TCR. But we knew TCR wasn’t necessarily going to be the only registration provider we’d ever integrate with. We didn’t want to build a TCR service. We wanted to build a registration service.
The architecture we arrived at was polymorphic. A registration could represent a TCR brand, a TCR campaign, or some future resource belonging to a provider we hadn’t integrated with yet.
At the time, we believed this was the correct design. I still think it was a great design.
I also wouldn’t ship it the same way today.
The problem we were trying to solve
Third-party registration systems don’t necessarily share a useful domain model.
TCR has brands and campaigns. Another provider might have similar concepts, completely different ones, or only support a subset. Even if two providers both have something called a “brand,” assuming they share fields, lifecycle states, validation rules, or capabilities is dangerous.
We didn’t have requirements for those hypothetical providers. That’s important. It’s easy to invent this:
BrandRegistrar
register(Brand)
But what exactly is a Brand? What if Provider A requires fields TCR doesn’t? What if Provider B doesn’t support brands? What if two providers use the same word for resources that aren’t actually equivalent? We would have been inventing commonality we couldn’t prove existed.
Instead, we generalized around something we actually knew. A registration had an identity and a type. The concrete registered resource could vary.
Registration
|
+-- TcrBrand
+-- TcrCampaign
+-- SomeFutureProviderBrand
+-- WhateverComesNext
Each concrete type could have its own fields, validation, serialization, and asynchronous workflow. That’s one of the things polymorphism is good at.
Polymorphism wasn’t the problem
I don’t subscribe to the idea that polymorphism is inherently a design smell. Used appropriately, it lets us generalize the things we actually know are common while preserving the differences that matter.
Our registration system was asynchronous. Different registration types could invoke different background jobs, communicate with different external systems, transition through different states, fail differently, and receive asynchronous updates from providers. A generic registration layer gave us common machinery around those workflows without pretending the providers themselves were identical.
I still like that design. Where I’d draw the line differently today is the API boundary. Internally, concepts like these were useful:
Registration
class
kind
kind_id
registrar
item
But the consumer’s problem wasn’t:
I would like to create a polymorphic registration of a particular class, kind, and registrar.
It was:
I need to register a 10DLC brand with TCR.
By exposing the generic registration model, we required consumers to understand an abstraction that primarily existed to make our implementation extensible.
Polymorphism isn’t evil. Making your consumers participate in your polymorphism probably is.
The registration engine can think in registrations. The consumer should be allowed to think in brands and campaigns.
If I were building it again, I’d still happily build the polymorphic engine. But I’d also be very clear with leadership about the cost:
We’re not finished when the generic engine works. We still owe consumers a simpler interface.
That last part can’t perpetually lose to the next feature because “registration already works.”
Then Rust entered the chat
There’s another problem architecture diagrams don’t show. We had to actually build the damn thing.
In Rust.
On a whiteboard:
Registration<T>
id
item: T
Beautiful. Then JSON arrives.
The API accepts some discriminator that tells us what kind of registration we’re dealing with at runtime. Rust, meanwhile, wants the concrete possibilities represented in a type system it can reason about at compile time.
That’s where the bill arrives. The abstraction has to survive serialization and deserialization, persistence, API schema generation, asynchronous jobs, validation, conversions, enums, traits, generics, match arms, and all the glue connecting those things.
Rust can model this extremely well. That doesn’t mean I want to maintain it.
And this was before AI agents could generate most of the mechanical scaffolding. There was no robot available to spit out another Registerable<T> implementation, update the appropriate enums and match arms, wire up serialization, generate tests, and patiently work through compiler errors.
We wrote it. Well, back in my day, we wrote our own boilerplate. Uphill. Both ways. And the compiler yelled at us the entire time.
AI changes those economics considerably. An agent can now produce a lot of that machinery while an engineer concentrates on the semantics. But that doesn’t make the architecture simpler, and it definitely doesn’t make the API easier for consumers to understand.
Dynamic languages make this much easier
This particular kind of runtime polymorphism is dramatically easier to express in a dynamic language. Ruby makes the idea almost offensively straightforward:
type = params[:type]
klass = REGISTERABLE_TYPES.fetch(type)
item = klass.new(params[:data])
Python isn’t fundamentally different:
registration_type = payload["type"]
cls = REGISTRATION_TYPES[registration_type]
item = cls(**payload["data"])
And Elixir can lean into pattern matching:
def deserialize(%{"type" => "tcr_brand", "data" => data}),
do: TcrBrand.from_map(data)
def deserialize(%{"type" => "tcr_campaign", "data" => data}),
do: TcrCampaign.from_map(data)
The language is perfectly comfortable receiving some data, inspecting it at runtime, and deciding what behavior should handle it. That’s attractive for serialization-heavy, highly polymorphic systems.
But the complexity didn’t disappear. You moved where you pay for correctness.
Rust makes the compiler carry a substantial portion of that burden. In a dynamic language, more of it moves to runtime validation and your test suite. That means I only want this trade when the test suite is production quality.
Every supported registration type needs serialization coverage. Invalid types need predictable failures. Required fields need validation. Provider-specific representations need isolation. Workflows need end-to-end tests. Backward compatibility needs protection.
With a mediocre test suite, runtime flexibility becomes a liability. With an excellent one, it can be an enormous productivity advantage.
Dynamic languages don’t eliminate the need for type safety. They let you choose where you pay for it. If you move that responsibility from the compiler to the test suite, the test suite had better deserve the promotion.
We thought this was the correct architecture
Retrospectives have a bad habit of turning into “look at this obviously stupid thing we used to do.” That’s not this story.
We discussed the architecture extensively and believed it was the correct design. We were deliberately trying not to paint ourselves into a corner. Additional providers were possible. We didn’t have enough information to invent a universal provider abstraction. The workflows were heterogeneous and asynchronous.
The polymorphic model addressed real problems, and I still think the underlying design is good. What changed was my understanding of appropriate complexity.
Given the team, resources, and actual requirements, we probably should have built something simpler first. That’s different from saying the architecture was wrong. An architecture can be logically correct, extensible, type-safe, and well engineered while still asking more of an organization than the problem currently justifies.
The startup trap
This is where startups make the problem especially interesting. You need to move quickly, and you also want to build a great product. You know “we’ll fix it later” is dangerous. You also know spending weeks perfecting abstractions for requirements that may never exist is dangerous.
So sometimes you build three quarters of what you really want. It works. The business needs something else, the team moves on, and nothing breaks.
That’s precisely why the remaining quarter becomes so difficult to prioritize: it works. Then, eventually, too many things depend on it to easily change it.
Technical debt doesn’t just sit there. It acquires dependents. Every new consumer makes the existing interface slightly more permanent.
That’s why I would communicate the trade differently today. Shipping the generic registration API might still be the right decision. But I would be explicit:
This gets us to market, but this is not where the abstraction should end. If we’re serious about this becoming a platform capability, we still owe consumers a simpler interface.
Because “we’ll finish it later” isn’t a plan.
What I’d do differently
I wouldn’t throw away the polymorphic model. Given the same problem, I would still want a mechanism capable of representing heterogeneous registration types without inventing commonality between providers that I couldn’t prove existed.
I’d still isolate provider-specific behavior. I’d still model the asynchronous workflows explicitly. I’d still avoid inventing a universal Brand because two vendors happened to use similar terminology.
But I’d be much more conservative about where that abstraction was allowed to escape. And given the team size and known requirements, I might simply implement TCR well first and wait. The second provider would teach us more about the correct abstraction than any amount of whiteboarding about a hypothetical second provider ever could.
That’s not an argument against architecture. It’s an argument for knowing when you have enough information to architect something.
The lesson
I don’t regret building sophisticated systems. I do regret some of the times I confused sophistication with appropriateness.
Polymorphism wasn’t the villain. Rust wasn’t the villain. Moving quickly wasn’t the villain. There wasn’t a villain.
There was a team trying to build a platform that wouldn’t paint the company into a corner, with incomplete information, finite resources, and software that needed to ship. That’s engineering.
The thing I’d do differently today is pay much more attention to where the abstraction ends—and make sure everyone understands the work isn’t finished just because the abstraction works.
Your implementation needs to understand its own complexity. Your consumers probably shouldn’t have to.