Designing for Change: How to Anticipate the Future Without Guessing It
Software design is the art of managing change. But what kind of change should we anticipate, and how should that shape our designs?
Software design is the art of managing change. But what kind of change should we anticipate, and how should that shape our designs?
In this post, we explore practical strategies for anticipating change, not by predicting the future, but by designing systems that remain flexible in the face of it.
The Wrong Question: What Will Change?
Attempting to predict exactly what will change is rarely effective yet it’s a common trap engineers fall into.
“Let’s build this feature now, in case we need it later.”
This leads to speculative designs — complex, over-engineered systems based on imagined futures. They’re hard to maintain, hard to explain, and often wrong.
At the other extreme are minimalists who advocate building only what is needed right now. This can feel lean and agile; until the first change request arrives. Then you discover your code is rigid, tangled, and difficult to adapt.
Both approaches fail for the same reason: they don’t give you a design that can adapt when reality shifts.
The Right Question: Where Might Change Occur?
Rather than trying to predict what will change, design for the possibility of change in specific places. This approach creates designs with room to grow, like parts joined together instead of being cast in stone.
Some general rules of thumb :
- Where there is one of something, there may soon be many.
- If a feature is tied to business growth (e.g. internationalization), it will likely change.
- If something is hardcoded (e.g. maxIterations = 100), ask whether it should be a parameter, and if not now, then when?
Product-Level Awareness: Context Is Everything
Some change is driven not by technology, but by business strategy. Domain knowledge helps.
If the app is consumer-facing, internationalization and growth will matter. If it serves enterprises, auditing, versioning, or data residency might become critical.
For example, if you ask the business, “Will users ever need to upload multiple documents here instead of just one?”, the answer can reveal whether your current design is too rigid and likely to break later.
The earlier you consider these questions, the cheaper the flexibility.
Engineering-Level Awareness: Prepare, Don’t Predict
Good engineers don’t predict what will happen. They build systems that are easy to change when something does.
Examples of low-cost, high-leverage flexibility:
- Replace booleans with enums if more states are plausible.
- Use interfaces, not concrete types, at key boundaries.
- Design for distribution if you might need scalability.
- Keep logs and track changes if you foresee debugging pain.
A Practical Compass for Handling Change
When it comes to design decisions, it’s not about being able to predict the exact change. Instead, it’s about making the design flexible enough to adapt when changes come.
Here’s how different mindsets stack up:
- Trying to predict the exact change often leads to unnecessary complexity and the wrong bets.
- Avoiding design altogether makes the code hard to change when new needs arise.
- Designing for flexibility keeps things simple now while making it easier to adapt later.
When facing a design decision, consider these questions:
Where is change likely to occur?
When is change likely to occur?
How severe would a change be to implement later?
What are low-cost bets I can make now to reduce future pain?
Good design strikes a balance: not overthinking every possibility, but not ignoring them either. It’s about making smart, low-cost choices now that won’t box you in later.
Abstractions and Change
Most developers agree that change is inevitable. But too often, we assume change is a storm to weather, rather than a phenomenon we can shape for. We design software to meet today’s needs, and then scramble to retrofit it for tomorrow’s surprises. The real question isn’t if change will happen, it’s whether your design will fracture or flex when it does.
To understand what makes some systems resilient and others brittle, we have to start by examining the abstractions that underpin them.
Because change doesn’t just rewrite logic, it reshapes meaning. And when your abstraction can’t carry that new meaning, the code breaks in silent, dangerous ways.
The question is not whether abstractions need to evolve when changes are introduced, the question is whether the initial abstractions were crafted in a manner that allows them to evolve without breaking at the seam.
Let’s explore this through a real-world scenario: of an online video course platform. A lesson is a core concept in any course, irrespective of the medium the course is delivered.
We’ll contrast two different design philosophies, a traditional approach, and a concept-based one and follow how they behave under pressure.
Traditional Design: The Illusion of Simplicity
In a traditional implementation, a Lesson is often treated as a single, indivisible object. The Lesson class holds the title, content link, instructor, and maybe even ordering within a course. The learner's progress is tracked in a LessonProgress keyed by lesson ID and user ID.
It works great, until the product team asks: “Can instructors schedule live deliveries of a lesson?”
You now need to introduce start times, instructor-specific delivery, and access gating. But there’s no concept of a Session or an Instance in your model. So you add a scheduledAt field to the Lesson. Then a timezone. Then a maxParticipants. Maybe a live flag.
The design starts leaking. One lesson is reused in two cohorts, but it only has one scheduledAt. Now you’re duplicating lesson records. Learner progress is attached to the lesson ID, but different learners attended different sessions. You code gets strewn with logic like:
if (lesson.isLive) {
if (lesson.timezone != null) {
ZonedDateTime lessonStart = ZonedDateTime.of(lesson.scheduledAt, ZoneId.of(lesson.timezone));
ZonedDateTime userNow = ZonedDateTime.now(ZoneId.of(user.getTimezone()));
if (userNow.isBefore(lessonStart)) {
//some logic to handle the scenario
}
} else {
if (now().isBefore(lesson.scheduledAt)) {
//some logic to handle the scenario
}
}
if (lesson.instructorId != null && !lesson.instructorId.equals(actualInstructorId)) {
//some logic to handle the scenario
}
if (lesson.hasFallbackRecording && lesson.fallbackRecordingUrl != null) {
//some logic to handle the scenario
}
if (lesson.isRescheduled && lesson.originalScheduledAt != null) {
//some logic to handle the scenario
}
}And just like that, the simplicity you bought up front becomes a tax you’ll pay every time something changes.
What Is a Lesson, Really?
Let’s digress a bit by asking the question: What is a Lesson, Really ?
A lesson isn’t just a video or a PDF. In the real world, it’s a layered construct:
The Idea of the Lesson:
What it is - independent of delivery or consumption
At its core, a lesson is an intentional unit of teaching. It’s defined by its objective, its content, and often its pedagogical structure. This layer represents the design of the learning experience, not its execution.
This idea exists outside of any particular student, instructor, or time. It’s reusable, versioned, and agnostic to delivery. You might change the content or tweak the goals over time, but its identity as a learning construct is stable.
Examples of attributes in this layer:
- The title of the lesson: “Understanding Recursion”
- Its learning objective: “Learners will be able to explain and implement recursion”
- Course materials: slides, video links, diagrams
- Embedded assessments or prompts
- Estimated time to complete
A lesson idea is what lets you say things like :
“This lesson is part of our AI track.”
“This lesson is reused across multiple batches.”
“This lesson was revised last quarter.”
It is not concerned with who is teaching it or when it happens. It’s the what and why, not the who or when.
The Contextual Instance
When and by whom the lesson is delivered
This layer is where the lesson idea enters the real world.
It represents a scheduled or instantiated occurrence of the lesson idea. It’s a binding between the abstract content and the logistics of its delivery: time, facilitator, platform, format, and target cohort.
Some systems might call this a session or class. But at its core, it’s the bridge between curriculum and calendar.
This is the level where you schedule a live class, assign it to an instructor, or publish it to a specific cohort. It answers questions like:
- Who is teaching this particular run of the lesson?
- When is it taking place?
- Is it live, pre-recorded, or self-paced?
- What platform is it delivered on? Zoom, in-person, LMS?
It might even embed configurations like:
- Maximum participants
- Associated assignments
- Rescheduling rules
- Language of delivery
The same lesson idea might be delivered in 10 different contexts across different time zones, formats, or instructors. Each one is a unique instance, but all point back to the same source idea.
The Personal Interaction:
What happens when a learner encounters that instance
This final layer is the learner’s individual journey through a particular contextual instance of a lesson. It’s where all the messiness of real behavior lives: attendance, engagement, feedback, completion, confusion, progress, and outcomes.
This layer is always unique to the learner. Two students in the same session may experience it very differently.
This is the layer that drives:
- Progress tracking
- Analytics (time spent, outcomes)
- Notes, questions, and feedback
- Personalized insights or interventions
- Certification or grading workflows
In the real world, you do not “complete a lesson” — you complete your interaction with a session of that lesson.
You’re not marking a row in the lesson table as “done.” You’re recording your personal learning history, tied to a unique delivery in a unique context.
These three layers exist in reality whether your code acknowledges them or not. The choice you make as a designer is whether to model them explicitly, or to squash them into a single data structure and deal with the consequences later.
Concept-Based Design: Meaning, Separated
In contrast, the concept-based approach starts from the domain, not the interface. A lesson isn’t a single thing — it’s a composition of intent, delivery, and experience. That composition is cleanly modeled as three distinct layers:
- LessonDefinition: the curriculum entity — reusable, versioned, and agnostic to time and audience. It captures the educational intent: what to teach, why it’s taught, and how it should be structured.
- DeliveryContext: the logistical and instructional binding of a lesson to a particular moment and group. It determines who is teaching, when it’s happening, in what format (live, recorded, self-paced), and through what medium or platform. You can have ten DeliveryContexts all pointing to the same LessonDefinition but tailored for different instructors, time zones, or batches.
- LearnerExperience: the learner’s specific interaction with a DeliveryContext. It captures progress, attendance, engagement, questions, submissions, and outcomes. Two learners attending the same session will have different LearnerExperience records. One might join late, skip the quiz, and submit an assignment late. Another might complete everything in real-time.
When scheduling arrives as a requirement, you don’t modify the LessonDefinition — because the pedagogical intent hasn’t changed. Instead, you create a new DeliveryContext that expresses the new logistical reality: this lesson will be taught live by Instructor X on Tuesday at 10am IST. The learners who sign up or are assigned to this session will receive a corresponding LearnerExperience record when they interact with it.
This change doesn’t require reinterpreting existing fields. It doesn’t demand adding if conditions to check whether a lesson is live or recorded. It doesn’t force the UI to bend around overloaded objects. It’s an additive model: a new dimension (time and delivery) layered on top of an existing foundation.
You want to add rescheduling? Change the DeliveryContext’s start time. You want to support multiple time zones? Create multiple DeliveryContexts. You want to support fallback from a live session to a recorded one? Link one DeliveryContext as a backup for another.
The model doesn’t just accept change — it welcomes it, because it was built on concepts that naturally expand without conflict. You don’t have to redesign it. You just keep populating it with richer meaning.
How Abstractions Handle Change
This leads us to the heart of the issue: How an abstraction handles change depends on how well it was aligned to the real world to begin with.
In the traditional model, the lesson abstraction was never designed to distinguish between intent, context, and interaction. So when a new kind of delivery (like scheduling) arrives, the system has no place to absorb it. You patch it in, and that patch leaks. The result is a system that works only by convention, not by truth. Every layer from the API to the UI now contains special logic to interpret the overstuffed lesson object.
In the concept-based design, the same change lands softly. Not because the future was predicted perfectly, but because the design was open to evolution in shape and dimension. Each new behavior has a structural home.
Change becomes an act of extension, not rupture.
Designing for Change Is Designing for Meaning
You don’t need to know the future to design for it. You just need to understand the present deeply enough to represent it truthfully. When you collapse multiple meanings into a single abstraction, you force every future change to fight that fiction.
But when your abstractions reflect the actual structure of the world — its actors, events, and interactions, you create a design that wants to change. One that metabolizes complexity instead of rejecting it.
Final Thoughts
Design is not about predicting the future. It’s about being ready for it.
Build for flexibility. Make change safe. Leave room for movement, not just structure. And remember:
That’s how you anticipate change — without pretending to see the future.