Attack of the Clones: How Kotlin Design Patterns Save the Day (Again)

Table of Contents

Attack of the Clones: How Kotlin Design Patterns Save the Day (Again)

If you’ve ever had the pleasure of working with generated classes—whether from Protobuf, Swagger, or yet another “magical” schema tool—you’ll know they’re about as customizable as a locked-down corporate laptop. The result? Multiple classes that all do basically the same thing (e.g., PersonA, PersonB, PersonC) but refuse to share a single interface.

In Kotlin, this quickly becomes a headache whenever you try to write neat, idiomatic code. You might think: “Surely these classes are the same under the hood. Why can’t they just get along?” But your attempts to unify them usually end with boilerplate conversions or heroic (and occasionally hacky) reflection.

In this post, we’ll look at how design patterns can ride to the rescue. Specifically, we want to see how the Adapter, Decorator, Facade, and Strategy patterns can help us treat these auto-generated classes more uniformly—without the risk of them getting overwritten every time we regenerate the code. We’ll also discuss a dedicated mapping layer, so your real domain models don’t end up beholden to the quirks of code generation.

Sound good? Then let’s dive into the problem itself and see why making changes to these classes directly often isn’t the best idea.

The Problem Explained

Let’s pretend you have a codebase where someone (maybe past-you in a moment of questionable foresight) decided to generate classes from different sources. One set comes from a Protobuf definition. Another might hail from a swagger/OpenAPI schema. A third might just be someone else’s idea of a great codegen tool.

Before you know it, you’re staring at something like:

data class PersonA(
val firstName: String,
val lastName: String,
val age: Int
// ...and maybe a few more fields
)

data class PersonB(
val firstName: String,
val lastName: String,
val age: Int
// ...and a few more fields with ironically the same names
)

data class PersonC(
val firstName: String,
val lastName: String,
val age: Int
// ...you get the idea
)

All these classes look eerily similar but don’t share a common parent or interface—nor do you get a say in it, because they’re auto-generated. You can’t just go in there and slap on an IPerson interface. The next time the code is regenerated, poof—your changes vanish like that free coffee in the break room.

Why Is This a Problem?

  1. Code Duplication
    Let’s say you need to print out a full name in a particular format. You end up writing three different extension functions or helper methods, each for PersonA, PersonB, and PersonC. They’re all the same logic, just repeating themselves. If you ever need to fix a bug or enhance the feature, you have to remember to do it in every single place.
  2. Boilerplate Conversion Code
    If one part of your system wants PersonA but another can only handle PersonB, you might need conversion utilities. Once you end up with four or five (or ten) different flavors, your conversion layer starts to resemble a labyrinth with minotaurs lurking in the corners. That’s a lot of needless overhead.
  3. Difficulty in Applying Polymorphism
    Typically, you’d do something like fun greetPerson(person: PersonInterface) to handle all the different types in one shot. But since they don’t share a common interface (and you’re not allowed to modify them), you’re left with less elegant options, like writing a string of when statements based on the class, or a bunch of overloaded methods—none of which are fun to maintain.

That’s where design patterns come in. We can’t (or shouldn’t) modify the generated code directly, so we’ll need to be clever in how we unify them. Next up, we’ll discuss exactly why messing with these generated classes is a bad idea. After all, an “elegant” hack tends to become less elegant once it breaks on the next regeneration cycle.

Why Not Modify the Classes Directly?

At this point, the pragmatic developer in you might be thinking: “Well, these generated classes are basically the same. Why not just give them all a common interface or base class?” That’s a fair question—until you realize that the next time the schema changes, your lovingly crafted modifications will be overwritten faster than you can say “continuous integration.”

Regenerated on Every Build

Many code-generation pipelines are automated. Protobuf, for instance, will gleefully replace your entire source file with a fresh version every time you regenerate stubs. Any local tweaks vanish, leaving you shaking your fist at your build server.

Owned by External Schema/Tools

Generated classes typically come from external definitions you don’t control. Protobuf, OpenAPI, or some magical YAML that someone in a corner office wrote. You can’t exactly call up the tool vendor and say, “Hey, could you let me tweak your code for my personal amusements?” (Well, you could, but I wouldn’t hold my breath.)

Separation of Concerns

Even if you could hack your changes into the generated files, you probably shouldn’t. Modifying generated classes tightly couples your domain logic to someone else’s schema definitions. Down that path lies madness: you’ll be forever chasing merges and updates just to keep your code afloat.

In other words, working around the generation process by hacking the output is about as appealing as writing a custom Kotlin compiler plugin just to rename your methods—technically possible, but guaranteed to cause trouble. Instead, let’s keep the generated code as-is and explore design patterns to do the heavy lifting elsewhere.

Design Pattern Solutions

So, you’ve got these auto-generated classes that you can’t touch without facing the wrath of the code-generation gods. Fear not—there are plenty of design patterns that can help you unify these classes without resorting to black magic. Below, we’ll look at four patterns you can use either individually or in concert.

Adapter Pattern

The Adapter Pattern is a go-to when you need to make a square peg fit in a round hole without actually carving up the peg. You create an interface (say, IPersonAdapter) that represents the functionality you want (getFullName(), getAge(), etc.). Then you write small adapter classes that implement that interface and delegate to the original auto-generated class.

// The interface we actually want to work with
interface IPersonAdapter {
fun getFullName(): String
fun getAge(): Int
}

// Adapters wrapping each generated class
class PersonAAdapter(private val personA: PersonA) : IPersonAdapter {
override fun getFullName() = "${personA.firstName} ${personA.lastName}"
override fun getAge() = personA.age
}

class PersonBAdapter(private val personB: PersonB) : IPersonAdapter {
override fun getFullName() = "${personB.firstName} ${personB.lastName}"
override fun getAge() = personB.age
}

// ...and so on for PersonC

Now you can treat all these generated objects the same way, at least from the perspective of the rest of your code:

fun greet(adapter: IPersonAdapter) {
println("Hello, ${adapter.getFullName()}! You’re ${adapter.getAge()} years old.")
}

Pros

  • Easy to implement and understand.
  • Doesn’t require modifying the generated classes (hallelujah).
  • Cleanly separates your “domain interface” from the generated code.

Cons

  • You’ll have to write an adapter for each class, which can become verbose if you have a hundred of these.
  • If a field changes in the generated class, you have to update the adapter accordingly (though that beats fighting the generation tool).

Decorator Pattern

The Decorator Pattern is like adding layers of clothing to an existing object. You still treat it as the same underlying type, but now it’s wearing a fancy hat. This is usually more relevant if you want to add new behavior or tweak existing behavior without changing the interface.

In our scenario, if you already had a way to treat all persons uniformly (say you do have a shared interface somewhere), but you want to add extra cross-cutting features—like logging, caching, or special validation—the Decorator Pattern can be handy. You wrap the existing adapter (or direct class instance) in another object that adds the new functionality.

It’s not the usual approach for unifying multiple distinct classes under one type, though—it’s more about extending or embellishing. Think: “Dress my Person up with some logging so I know when getFullName() is called.”

First, we define our IPerson interface. Then we show how a base adapter (which we might call PersonAAdapter) wraps a generated PersonA class:

// Common interface that we want to use throughout the application
interface IPerson {
fun getFullName(): String
fun getAge(): Int
}

// Example generated class (simplified)
data class PersonA(
val firstName: String,
val lastName: String,
val age: Int
)

// A basic adapter that implements IPerson using PersonA
class PersonAAdapter(private val personA: PersonA) : IPerson {
override fun getFullName(): String {
return "${personA.firstName} ${personA.lastName}"
}

override fun getAge(): Int {
return personA.age
}
}

At this point, PersonAAdapter is a simple wrapper (or adapter) that allows us to treat PersonA objects as IPerson. But we haven’t shown how to decorate it yet—that’s where the Decorator Pattern shines.

The classic approach to the Decorator Pattern in object-oriented programming is to create a decorator class that also implements IPerson, takes an IPerson in its constructor, and delegates calls to it. This makes it easy to insert new behaviors before or after the delegate calls.

// Base Decorator: implements the same interface and stores a reference to an IPerson
open class PersonDecorator(
private val delegate: IPerson
) : IPerson {

override fun getFullName(): String {
return delegate.getFullName()
}

override fun getAge(): Int {
return delegate.getAge()
}
}

Notice that PersonDecorator is open so it can be subclassed by other decorators that add specific functionality. Now we can create specialized decorators that extend PersonDecorator.

Example 1: Logging Decorator

Sometimes you just want to know when someone is calling your methods (maybe to debug some odd behavior). A logging decorator can capture the calls:

class LoggingPersonDecorator(
delegate: IPerson
) : PersonDecorator(delegate) {

override fun getFullName(): String {
println("LoggingPersonDecorator: getFullName() called")
return super.getFullName()
}

override fun getAge(): Int {
println("LoggingPersonDecorator: getAge() called")
return super.getAge()
}
}

How to Use It

fun main() {
val personA = PersonA("Alice", "Smith", 30)
val personAdapter = PersonAAdapter(personA)

// Wrap the adapter in our logging decorator
val loggingDecorator = LoggingPersonDecorator(personAdapter)

// Calls go through the decorator, which adds logging behavior
println("Full name: ${loggingDecorator.getFullName()}")
println("Age: ${loggingDecorator.getAge()}")
}

Output (illustrative):

LoggingPersonDecorator: getFullName() called
Full name: Alice Smith
LoggingPersonDecorator: getAge() called
Age: 30

Example 2: Validation Decorator

Let’s say you want to ensure that any getAge() call doesn’t return a negative number (maybe the data is untrusted or sometimes incomplete). You could add a “validation” layer without changing your base adapter:

class ValidatingPersonDecorator(
delegate: IPerson
) : PersonDecorator(delegate) {

override fun getAge(): Int {
val age = super.getAge()
require(age >= 0) { "Age cannot be negative!" }
return age
}
}

Chaining Multiple Decorators

You can combine decorators by wrapping them around each other. Here’s how you might chain both the logging and validating decorators:

fun main() {
val personA = PersonA("Bob", "Brown", 25)
val baseAdapter = PersonAAdapter(personA)

// Layer 1: Logging
val loggingDecorator = LoggingPersonDecorator(baseAdapter)

// Layer 2: Validation
val validatingDecorator = ValidatingPersonDecorator(loggingDecorator)

// Now calls pass through both decorators
println("Full name: ${validatingDecorator.getFullName()}")
println("Age: ${validatingDecorator.getAge()}")
}

In this chain:

  1. The call hits the ValidatingPersonDecorator.
  2. It calls super.getAge(), which passes it up to LoggingPersonDecorator.
  3. The logger prints a message, then calls super.getAge(), which finally delegates to the original PersonAAdapter.
  4. The PersonAAdapter fetches the actual data from PersonA.
  5. Control unwinds back through the decorators, letting you add or check anything you want at each stage.

Facade Pattern

The Facade Pattern is about providing a simpler interface to a complex subsystem. If your generated classes have the complexity of a Rube Goldberg machine—multiple parameters, internal states, or labyrinthine dependencies—you can create a Facade that presents a uniform API to the outside world.

For example, maybe you have PersonA which sometimes has middle names, and PersonB which randomly has a “nickname” field. A Facade could wrap both of these and provide a single getDisplayName() method. Internally, it decides how to handle the middle name or nickname nonsense.

This is especially helpful when you want to hide the complexity of multiple APIs or classes from client code. Your Facade can orchestrate everything behind the scenes. It’s not just for “unifying” in the sense of a base interface, but it does unify the usage pattern for external callers who just need a straightforward way to say “Hey, gimme this person’s name.”

Below is a concise example of how a Facade can wrap multiple generated classes (e.g., PersonA, PersonB, PersonC) behind a single, simpler interface. The goal is to hide the nitty-gritty differences between each generated class so client code has just one place to call.

// Hypothetical generated classes
data class PersonA(val firstName: String, val lastName: String, val age: Int)
data class PersonB(val givenName: String, val familyName: String, val yearsOld: Int)
data class PersonC(val nameParts: List<String>, val ageValue: Int)

// A simple DTO that represents a unified "person" in your domain
data class PersonDTO(val fullName: String, val age: Int)

// The Facade interface or class — client code only needs to know about this
class PersonFacade {

// Each method knows how to handle the specifics of a different generated class
fun createFromPersonA(personA: PersonA): PersonDTO {
return PersonDTO(
fullName = "${personA.firstName} ${personA.lastName}",
age = personA.age
)
}

fun createFromPersonB(personB: PersonB): PersonDTO {
return PersonDTO(
fullName = "${personB.givenName} ${personB.familyName}",
age = personB.yearsOld
)
}

fun createFromPersonC(personC: PersonC): PersonDTO {
val name = personC.nameParts.joinToString(" ")
return PersonDTO(fullName = name, age = personC.ageValue)
}
}

// --- USAGE EXAMPLE ---
fun main() {
val personA = PersonA("Alice", "Smith", 30)
val personB = PersonB("Bob", "Brown", 25)
val personC = PersonC(listOf("Charlie", "Chaplin"), 40)

val facade = PersonFacade()

// Behind the scenes, the facade knows how to handle each type
val dtoA = facade.createFromPersonA(personA)
val dtoB = facade.createFromPersonB(personB)
val dtoC = facade.createFromPersonC(personC)

println(dtoA) // PersonDTO(fullName=Alice Smith, age=30)
println(dtoB) // PersonDTO(fullName=Bob Brown, age=25)
println(dtoC) // PersonDTO(fullName=Charlie Chaplin, age=40)
}

How It Helps

  • Simplification: Client code only interacts with PersonFacade and PersonDTO; it doesn’t need to know the differences among PersonA, PersonB, PersonC.
  • Isolation: Changes to the generated classes (like a renamed field in PersonB) are contained in the facade. The rest of your codebase remains oblivious.
  • Single Entry Point: Rather than scattering adaptation logic across the codebase, you keep it in one place—easy to find, easy to maintain.

Strategy Pattern

Finally, the Strategy Pattern might come in handy if the logic for handling these classes varies slightly but you want to swap it in and out. Maybe you have different approaches for parsing or displaying person data depending on which class you’re dealing with.

Using Strategy, you could define a single interface—like PersonFormattingStrategy—with a method formatName(person: Any): String. Then for each type of person class (A, B, C), you provide a different strategy implementation. When your code needs to format a person’s name, it picks the right strategy. This is less direct than Adapter for simply unifying classes, but it’s great if the variation in behavior is your real problem.

In reality, you might combine these patterns. For instance, an Adapter pattern might unify the classes under a single interface, and then a Strategy pattern is used to choose which adapter is used at runtime. In the next section, we’ll talk about a more general approach to keep your domain model from being polluted by these code-generated classes: the good old mapping layer.

Below is a more playful (yet instructive) example that demonstrates how the Strategy Pattern can unify different “person” classes—each with its own quirks—under a common set of behaviors. The twist: We’ll use a generic strategy interface and a “handler” that picks the right strategy at runtime.

// Imagine these are generated classes you can't modify
data class PersonA(val firstName: String, val lastName: String, val age: Int)
data class PersonB(val givenName: String, val familyName: String, val yearsOld: Int)
data class PersonC(val nameParts: List<String>, val ageValue: Int)

// Step 1: Define a generic Strategy Interface
interface PersonReadingStrategy<T> {
fun extractName(person: T): String
fun extractAge(person: T): Int
}

// Step 2: Provide different strategy implementations
object PersonAStrategy : PersonReadingStrategy<PersonA> {
override fun extractName(person: PersonA): String {
return "${person.firstName} ${person.lastName}"
}
override fun extractAge(person: PersonA): Int {
return person.age
}
}

object PersonBStrategy : PersonReadingStrategy<PersonB> {
override fun extractName(person: PersonB): String {
return "${person.givenName} ${person.familyName}"
}
override fun extractAge(person: PersonB): Int {
return person.yearsOld
}
}

object PersonCStrategy : PersonReadingStrategy<PersonC> {
override fun extractName(person: PersonC): String {
return person.nameParts.joinToString(" ")
}
override fun extractAge(person: PersonC): Int {
return person.ageValue
}
}

// Step 3: A "Person Handler" that picks the right strategy at runtime
class PersonHandler {

// For illustration, let's store strategies in a map keyed by class type
private val strategies: MutableMap<Class<*>, PersonReadingStrategy<*>> = mutableMapOf(
PersonA::class.java to PersonAStrategy,
PersonB::class.java to PersonBStrategy,
PersonC::class.java to PersonCStrategy
)

fun registerStrategy(clazz: Class<*>, strategy: PersonReadingStrategy<*>) {
// In case you want to add or override strategies at runtime
strategies[clazz] = strategy
}

fun processPerson(person: Any) {
val strategy = strategies[person.javaClass]
?: error("No strategy registered for class ${person.javaClass.simpleName}")

// Because we store raw PersonReadingStrategy<*>, we need a little unsafe cast
@Suppress("UNCHECKED_CAST")
val typedStrategy = strategy as PersonReadingStrategy<Any>

val name = typedStrategy.extractName(person)
val age = typedStrategy.extractAge(person)

println("Processed Person: Name = $name, Age = $age")
}
}

// --- USAGE EXAMPLE ---
fun main() {
val handler = PersonHandler()

val alice = PersonA("Alice", "Smith", 30)
val bob = PersonB("Bob", "Brown", 25)
val charlie = PersonC(listOf("Charlie", "Chaplin"), 40)

handler.processPerson(alice)
handler.processPerson(bob)
handler.processPerson(charlie)
}

How It Works?

  1. PersonReadingStrategy<T>
    We define an interface that outlines how to extract a name and an age from a generic type T. Each concrete person type gets its own strategy that knows how to handle the specifics of its fields.
  2. Strategy Implementations (PersonAStrategy, PersonBStrategy, PersonCStrategy)
    Each one knows how to extract a name/age from a different generated class—ensuring we keep that logic in a single place.
  3. PersonHandler
    This is where the magic happens. It picks the right strategy for any given person object by looking up its class. Once found, it uses the strategy to do the actual work of extracting data.
    • We store strategies in a Map<Class<*>, PersonReadingStrategy<*>>.
    • A generic cast is used because Kotlin’s generics and reflection get a bit squeamish around raw types, but it’s manageable for demonstration.

Why It’s Interesting

  • Runtime Flexibility: You can register or replace strategies dynamically. If next week someone gives you PersonZ with fields like given, surname, secretAlias, you simply add PersonZStrategy and call registerStrategy(PersonZ::class.java, PersonZStrategy).
  • Keeps Generated Classes Separate: Each generated class is handled by a dedicated strategy, so the rest of your code can just say, “Hey, PersonHandler, here’s a new object. Figure it out!”
  • No Leaking Implementation Details: The client code doesn’t care which strategy is used; it’s happy to let the PersonHandler do that heavy lifting.

Real-world Application Scenarios

So where might you run into these pesky, nearly identical generated classes? Unfortunately, everywhere. Here are a few real-life cases where unifying them with design patterns or a mapping layer is not just a cute idea, but a lifeline:

  1. APIs Returning Similar but Distinct Response Types
    Maybe you’re calling one service that returns a PersonA JSON structure, another that returns a PersonB format, and yet another that—for reasons unknown—believes in fully spelled-out fields like ageInYears. You’d like to treat them all as people (shocking, I know). A common interface or mapping layer takes the chaos out of your code.
  2. Legacy Code vs. New Code
    You might have a legacy system that’s stuck with PersonA (which was generated back in 2010), and a shiny new microservice that just introduced PersonB. Instead of rewriting half your codebase to align with the new schema, you can gracefully adapt or convert between them. Your future self will thank you.
  3. Schema Evolution
    Over time, Protobuf or OpenAPI schemas will change—fields get renamed, types get split into subtypes, or brand-new fields appear. By using an Adapter or mapping layer, you can isolate these changes in one corner of your codebase. That way, your domain logic remains blissfully unaware of the ongoing drama in schema-land.

In short, whenever you’re dealing with multiple data definitions that share 95% of the same DNA, unifying them through patterns or a dedicated domain model layer can save you from a nest of switch statements and half-baked conversion hacks. Instead, you get clean code, maintainable logic, and fewer late-night “why is PersonA failing but PersonB works” debugging sessions.

Trade-offs & Considerations

Before you dive headfirst into these design patterns or crafting an elaborate mapping layer, it’s worth pausing to consider the potential pitfalls and compromises you’ll make along the way. Nothing’s free in software engineering—especially not neat, maintainable code.

  1. Performance Implications
    • Wrapping objects in adapters or converting them to new DTOs takes time and memory—albeit usually negligible in typical business applications. If you’re dealing with hundreds of thousands of PersonXYZ objects in a performance-critical environment, these small hits could stack up faster than your coffee cups during a crunch.
  2. Maintenance Overhead
    • Every time the generated classes change—new fields, renamed methods, etc.—you’ll need to update your adapters or mapping functions. While it’s usually straightforward, someone has to remember to do it. Overworked devs (i.e., all of us) can forget, leading to bugs or stale code.
  3. Code Generation Customization Options
    • Some code generators (like certain Protobuf plugins or OpenAPI generators) let you customize the output via templates, annotations, or config files. If you can add interfaces or base classes that way, it might reduce the need for adapters. Just remember, you’re stepping into custom territory that can break with each tool version upgrade—so weigh the convenience against possible future pain.
  4. Reflection-based Approaches
    • In Kotlin, you could consider reflection to generically handle fields, especially if they have consistent names. But reflection is slower, can be more fragile, and is prone to ugly runtime errors if field names change. Use it sparingly—like salt in your cooking, a sprinkle might help, but a handful can ruin everything.
  5. Learning Curve & Readability
    • Introducing design patterns (Adapter, Facade, Strategy, etc.) adds abstraction layers. Make sure the rest of your team understands these patterns and why you’re using them. Otherwise, you might find your carefully crafted design dismantled by the next person who thinks, “Let’s just do a quick fix here.”

Ultimately, these trade-offs can be well worth it—especially if you’d prefer to keep your main codebase clean and your interactions with generated classes well-defined. As with all design decisions, it’s about balancing short-term convenience against long-term maintainability (and your own sanity).

Conclusion

Dealing with multiple, almost-identical classes from code-generation tools can feel like babysitting triplets who insist on dressing differently—despite all looking alike to everyone else. But fear not: there are proven design patterns (Adapter, Decorator, Facade, Strategy) and straightforward mapping approaches that let you unify these classes while keeping your sanity intact.

  • Adapter is often the easiest, cleanest place to start. Write an interface that defines what you actually need, then create small wrapper classes around each generated type.
  • Decorator helps if you need to tack on new features without changing the original interface (useful for cross-cutting concerns).
  • Facade hides complexity behind a simpler, consolidated interface, which is a relief if your generated classes are labyrinthine.
  • Strategy is perfect when you want to pick the right behavior at runtime for each of those lookalike classes.
  • Mapping & Conversion Layers let you transform all these data lumps into one harmonious DTO, so your domain remains unpolluted by external schema drama.

Whichever route you choose, the key is keeping your domain logic isolated from the quirks of generated code. The next time your schema changes and regenerates classes with a surprise field named middleName2, you’ll be glad your main codebase only needs a small tweak to an adapter or mapping function—rather than a root-and-branch refactor.

In short: unify, don’t multiply—and keep your precious Kotlin code from getting overrun by a horde of slightly different Person variants. Your future self (and your fellow developers) will thank you.

Leave a Reply

Your email address will not be published. Required fields are marked *