From If-Else to DSL: How Software Evolves Toward Configuration-Driven Systems
From hardcoded logic to configuration-driven architectures, with complete Java examples.
The Use Case
A notification system with one goal: send messages to users through various channels.
All stages implement this same capability. The difference: how much code changes when you add a new channel.
Stage 1: Feature-Driven Code (If-Else)
Style: Hardcoded branches
Change impact: Modify existing code for every new feature
The Code
You start with email notifications:
public class NotificationService {
public void notify(User user, String message) {
System.out.println("Email to " + user.getEmail() + ": " + message);
}
}
Then product asks for SMS. You add a branch:
public class NotificationService {
public void notify(User user, String message, String channel) {
if (channel.equals("email")) {
System.out.println("Email to " + user.getEmail() + ": " + message);
} else if (channel.equals("sms")) {
System.out.println("SMS to " + user.getPhone() + ": " + message);
}
}
}
Then Slack. Then push notifications:
public class NotificationService {
public void notify(User user, String message, String channel) {
if (channel.equals("email")) {
System.out.println("Email to " + user.getEmail() + ": " + message);
} else if (channel.equals("sms")) {
System.out.println("SMS to " + user.getPhone() + ": " + message);
} else if (channel.equals("slack")) {
System.out.println("Slack to " + user.getSlackId() + ": " + message);
} else if (channel.equals("push")) {
System.out.println("Push to " + user.getDeviceId() + ": " + message);
}
}
}
What’s Happening
Every new channel means:
- Open this file
- Add another
else if - Risk breaking existing logic
- Retest everything
Pros
- Works
- Easy to understand
- No abstraction overhead
Cons
- Every new channel = code change
- Testing gets harder
- Single file grows forever
When to Stop Here
- You have 2-3 channels, unlikely to add more
- Prototype / MVP stage
- Team is small and moves fast
Stage 2: Polymorphism (Interface + Implementations)
Style: Abstract behavior behind interface
Change impact: Add new class, no modification to existing code
The Trigger
You notice: same operation, different behavior. Time to extract an interface.
The Code
// The abstraction
public interface Notifier {
void send(User user, String message);
}
// Implementations
public class EmailNotifier implements Notifier {
@Override
public void send(User user, String message) {
System.out.println("Email to " + user.getEmail() + ": " + message);
}
}
public class SmsNotifier implements Notifier {
@Override
public void send(User user, String message) {
System.out.println("SMS to " + user.getPhone() + ": " + message);
}
}
public class SlackNotifier implements Notifier {
@Override
public void send(User user, String message) {
System.out.println("Slack to " + user.getSlackId() + ": " + message);
}
}
// Usage
public class NotificationService {
public void notify(Notifier notifier, User user, String message) {
notifier.send(user, message);
}
}
Adding Feature E: Discord
Just add a new class. No existing code changes.
public class DiscordNotifier implements Notifier {
@Override
public void send(User user, String message) {
System.out.println("Discord to " + user.getDiscordId() + ": " + message);
}
}
What Changed
| Before (If-Else) | After (Polymorphism) |
|---|---|
| Modify existing code | Add new class |
| One file grows | Multiple focused files |
| Behavior in branches | Behavior in objects |
Pros
- Open/Closed: open for extension, closed for modification
- Each notifier is testable in isolation
- Clear separation of concerns
Cons
- Still need something to decide which notifier to use
- More files to manage
When to Stop Here
- Channel selection is decided at compile time
- You inject notifiers via dependency injection
- 3-10 channels, moderate growth rate
Stage 3: Generics (Type-Safe Abstraction)
Style: Abstract over types, not just behavior
Change impact: Add new class with specific types
The Trigger
You notice: different channels need different user types and message formats.
- Email needs
EmailUserandEmailMessage - Slack needs
SlackUserandSlackPayload - Pushing a
Stringto everything loses type safety
The Code
// Generic interface
public interface Notifier<U, M> {
void send(U recipient, M message);
}
// Type-safe implementations
public class EmailNotifier implements Notifier<EmailUser, EmailMessage> {
@Override
public void send(EmailUser recipient, EmailMessage message) {
System.out.println("Email to " + recipient.getEmail() + ": " + message.getSubject());
}
}
public class SmsNotifier implements Notifier<PhoneUser, String> {
@Override
public void send(PhoneUser recipient, String message) {
System.out.println("SMS to " + recipient.getPhone() + ": " + message);
}
}
public class SlackNotifier implements Notifier<SlackUser, SlackPayload> {
@Override
public void send(SlackUser recipient, SlackPayload message) {
System.out.println("Slack to " + recipient.getSlackId() + ": " + message.getText());
}
}
The Domain Types
// User types - each channel has specific user data
public record EmailUser(String name, String email) {}
public record PhoneUser(String name, String phone) {}
public record SlackUser(String name, String slackId, String workspace) {}
public record DiscordUser(String name, String discordId, String serverId) {}
// Message types - each channel has specific message format
public record EmailMessage(String subject, String body, List<String> cc) {}
public record SlackPayload(String text, String channel, List<Attachment> attachments) {}
public record DiscordEmbed(String title, String description, String color) {}
How Business Code Calls Them
With Dependency Injection (Spring Example)
@Service
public class AlertService {
private final Notifier<EmailUser, EmailMessage> emailNotifier;
private final Notifier<PhoneUser, String> smsNotifier;
private final Notifier<SlackUser, SlackPayload> slackNotifier;
// Spring injects the correct implementations
public AlertService(
Notifier<EmailUser, EmailMessage> emailNotifier,
Notifier<PhoneUser, String> smsNotifier,
Notifier<SlackUser, SlackPayload> slackNotifier) {
this.emailNotifier = emailNotifier;
this.smsNotifier = smsNotifier;
this.slackNotifier = slackNotifier;
}
public void sendUrgentAlert(User user, String alertMessage) {
// Send via all channels - each with proper types
// Email
EmailUser emailUser = new EmailUser(user.getName(), user.getEmail());
EmailMessage email = new EmailMessage("URGENT: " + alertMessage, alertMessage, List.of());
emailNotifier.send(emailUser, email);
// SMS - simple string message
PhoneUser phoneUser = new PhoneUser(user.getName(), user.getPhone());
smsNotifier.send(phoneUser, "URGENT: " + alertMessage);
// Slack
SlackUser slackUser = new SlackUser(user.getName(), user.getSlackId(), "company");
SlackPayload slack = new SlackPayload(":rotating_light: " + alertMessage, "#alerts", List.of());
slackNotifier.send(slackUser, slack);
}
}
// Spring configuration
@Configuration
public class NotifierConfig {
@Bean
public Notifier<EmailUser, EmailMessage> emailNotifier() {
return new EmailNotifier();
}
@Bean
public Notifier<PhoneUser, String> smsNotifier() {
return new SmsNotifier();
}
@Bean
public Notifier<SlackUser, SlackPayload> slackNotifier() {
return new SlackNotifier();
}
}
Generic Service Layer
public class NotificationService {
// Generic method - works with any notifier
public <U, M> void send(Notifier<U, M> notifier, U recipient, M message) {
try {
notifier.send(recipient, message);
log.info("Sent notification to {}", recipient);
} catch (Exception e) {
log.error("Failed to send notification", e);
throw new NotificationException("Failed to send", e);
}
}
// Batch send - same types enforced
public <U, M> void sendBatch(Notifier<U, M> notifier, List<U> recipients, M message) {
for (U recipient : recipients) {
send(notifier, recipient, message);
}
}
}
// Usage
NotificationService service = new NotificationService();
EmailNotifier emailNotifier = new EmailNotifier();
List<EmailUser> customers = List.of(
new EmailUser("Alice", "alice@example.com"),
new EmailUser("Bob", "bob@example.com")
);
EmailMessage promo = new EmailMessage("Sale!", "50% off today", List.of());
service.sendBatch(emailNotifier, customers, promo); // Type safe batch send
What Happens If Types Don’t Match?
EmailNotifier emailNotifier = new EmailNotifier();
SlackUser slackUser = new SlackUser("ops", "#general", "company");
String message = "Hello";
// COMPILE ERROR: incompatible types
emailNotifier.send(slackUser, message);
// Required: EmailUser, EmailMessage
// Found: SlackUser, String
The compiler catches mistakes. No runtime surprises.
Adding Features E, F, G
// Feature E: Discord
public class DiscordNotifier implements Notifier<DiscordUser, DiscordEmbed> {
@Override
public void send(DiscordUser recipient, DiscordEmbed message) {
System.out.println("Discord to " + recipient.getDiscordId() + ": " + message.getTitle());
}
}
// Feature F: WhatsApp
public class WhatsAppNotifier implements Notifier<WhatsAppUser, WhatsAppMessage> {
@Override
public void send(WhatsAppUser recipient, WhatsAppMessage message) {
System.out.println("WhatsApp to " + recipient.getWhatsAppId() + ": " + message.getBody());
}
}
// Feature G: Telegram
public class TelegramNotifier implements Notifier<TelegramUser, TelegramMessage> {
@Override
public void send(TelegramUser recipient, TelegramMessage message) {
System.out.println("Telegram to " + recipient.getTelegramId() + ": " + message.getText());
}
}
What Changed
| Polymorphism | Generics |
|---|---|
Notifier with User, String
|
Notifier<U, M> with specific types |
| Runtime type errors | Compile-time type safety |
| Cast everywhere | No casting |
Pros
- Compile-time type safety
- Each channel has proper domain types
- IDE autocomplete works correctly
Cons
- More types to manage
- Still need a factory or registry to create instances
When to Stop Here
- You want maximum type safety
- Different channels have genuinely different data models
- Team is comfortable with generics
Stage 4: Registry (Remove Factory Switch)
Style: Register implementations by name
Change impact: Register new class, no factory modification
The Trigger
You still have a factory with a switch:
// This breaks Open/Closed principle
public class NotifierFactory {
public static Notifier<?, ?> create(String type) {
return switch (type) {
case "email" -> new EmailNotifier();
case "sms" -> new SmsNotifier();
case "slack" -> new SlackNotifier();
case "discord" -> new DiscordNotifier(); // Must modify for each new type
default -> throw new IllegalArgumentException("Unknown: " + type);
};
}
}
Every new notifier = modify the factory. That’s not truly open/closed.
The Code
public class NotifierRegistry {
private final Map<String, Supplier<Notifier<?, ?>>> registry = new HashMap<>();
public void register(String type, Supplier<Notifier<?, ?>> supplier) {
registry.put(type, supplier);
}
public Notifier<?, ?> create(String type) {
Supplier<Notifier<?, ?>> supplier = registry.get(type);
if (supplier == null) {
throw new IllegalArgumentException("Unknown notifier: " + type);
}
return supplier.get();
}
}
Usage
// At startup - register implementations
NotifierRegistry registry = new NotifierRegistry();
registry.register("email", EmailNotifier::new);
registry.register("sms", SmsNotifier::new);
registry.register("slack", SlackNotifier::new);
// Adding Feature E: just register, no code modification
registry.register("discord", DiscordNotifier::new);
// At runtime - create by name
Notifier<?, ?> notifier = registry.create("email");
What Changed
| Factory Switch | Registry |
|---|---|
| Modify factory for each type | Register at startup |
| Hardcoded mapping | Dynamic mapping |
| Breaks Open/Closed | Follows Open/Closed |
Pros
- True Open/Closed: add new types without modifying existing code
- Can load from configuration
- Enables plugin architectures
Cons
- Loses some type safety (wildcards)
- Registration must happen somewhere
- Runtime errors instead of compile-time
When to Stop Here
- You need dynamic notifier selection at runtime
- Building a plugin system
- Types are loaded from configuration
Stage 5: Table-Driven (Configuration Controls Behavior)
Style: Data describes behavior
Change impact: Add row to table, no code change
The Trigger
You realize: most notifiers do the same thing with different parameters.
- Different endpoint
- Different message template
- Different retry count
Why write a new class for each?
The Code
// Configuration as data
public record NotifierConfig(
String endpoint,
String template,
int retries
) {}
// Generic executor
public class TableDrivenNotifier {
private final Map<String, NotifierConfig> table = Map.of(
"email", new NotifierConfig("smtp.server.com", "Email to %s: %s", 3),
"sms", new NotifierConfig("sms.api.com", "SMS to %s: %s", 2),
"slack", new NotifierConfig("slack.api.com", "Slack to %s: %s", 1),
"discord", new NotifierConfig("discord.api.com", "Discord to %s: %s", 1)
);
public void send(String type, String recipient, String message) {
NotifierConfig config = table.get(type);
if (config == null) {
throw new IllegalArgumentException("Unknown type: " + type);
}
String payload = String.format(config.template(), recipient, message);
callApiWithRetry(config.endpoint(), payload, config.retries());
}
private void callApiWithRetry(String endpoint, String payload, int retries) {
for (int i = 0; i <= retries; i++) {
try {
System.out.println("Calling " + endpoint + " with: " + payload);
return; // success
} catch (Exception e) {
if (i == retries) throw e;
}
}
}
}
Adding Feature H: Telegram
Just add a row:
"telegram", new NotifierConfig("telegram.api.com", "Telegram to %s: %s", 2)
No new class. No code change. Just data.
Externalize to JSON
{
"email": {
"endpoint": "smtp.server.com",
"template": "Email to %s: %s",
"retries": 3
},
"sms": {
"endpoint": "sms.api.com",
"template": "SMS to %s: %s",
"retries": 2
},
"telegram": {
"endpoint": "telegram.api.com",
"template": "Telegram to %s: %s",
"retries": 2
}
}
Now adding a channel = editing a config file. Zero code deployment.
What Changed
| Registry | Table-Driven |
|---|---|
| Register class per type | Add row per type |
| Code defines behavior | Data defines behavior |
| Deploy code for new type | Deploy config for new type |
Pros
- Add channels without code changes
- Non-developers can add channels
- Hot reload possible
Cons
- All channels must fit the same pattern
- Custom logic requires escape hatches
- Harder to debug (behavior not in code)
When to Stop Here
- Channels are similar (same algorithm, different parameters)
- You want ops/product to configure without developers
- High rate of channel additions
Stage 6: Rule Engine (Policies Replace Branches)
Style: Declarative rules control decisions
Change impact: Add rules to table, no code change
The Trigger
Business logic appears:
- VIP users get SMS for urgent messages
- Regular users get email only
- Night hours use push only
You could add if-else back… or externalize the rules.
The Code
// Rule definition
public record NotificationRule(
String userType,
String urgency,
String channel,
int retries
) {
public boolean matches(User user, Context context) {
return user.getType().equals(userType)
&& context.getUrgency().equals(urgency);
}
}
// Rule engine
public class RuleBasedNotifier {
private final List<NotificationRule> rules;
private final NotifierRegistry registry;
public RuleBasedNotifier(List<NotificationRule> rules, NotifierRegistry registry) {
this.rules = rules;
this.registry = registry;
}
public void notify(User user, String message, Context context) {
for (NotificationRule rule : rules) {
if (rule.matches(user, context)) {
Notifier<?, ?> notifier = registry.create(rule.channel());
// Apply retry wrapper if needed
sendWithRetry(notifier, user, message, rule.retries());
return;
}
}
throw new IllegalStateException("No matching rule for user: " + user);
}
private void sendWithRetry(Notifier<?, ?> notifier, User user, String message, int retries) {
// Simplified - real implementation would be type-safe
System.out.println("Sending via " + notifier.getClass().getSimpleName() +
" with " + retries + " retries");
}
}
Rules as Data
List<NotificationRule> rules = List.of(
new NotificationRule("VIP", "HIGH", "sms", 3),
new NotificationRule("VIP", "LOW", "email", 1),
new NotificationRule("REGULAR", "HIGH", "push", 2),
new NotificationRule("REGULAR", "LOW", "email", 1)
);
Adding a New Rule
Business says: “Premium users should get Slack for high urgency.”
new NotificationRule("PREMIUM", "HIGH", "slack", 2)
No code change. Just a new rule.
What Changed
| Table-Driven | Rule Engine |
|---|---|
| Data describes channels | Data describes decisions |
| “How to send” is configurable | “What to do when” is configurable |
| Parameters externalized | Business logic externalized |
Pros
- Business rules as data
- Product/ops can modify behavior
- Audit trail of rule changes
Cons
- Complex rule interactions
- Testing rules requires simulation
- Debugging gets harder
When to Stop Here
- Business logic changes frequently
- Non-technical stakeholders need to modify behavior
- You need audit trails for decisions
Stage 7: DSL (When Configuration Becomes Language)
Style: Domain-specific language for complex rules
Change impact: Write new rule in DSL syntax
The Trigger
Rules get complex:
- Conditions need AND/OR logic
- Rules need ordering and priority
- Actions need sequencing
Flat tables can’t express this anymore.
The DSL
rule "VIP urgent notification"
when
user.type == "VIP" AND urgency == "HIGH"
then
send sms retry 3
send email retry 1
end
rule "Night mode"
when
time.hour >= 22 OR time.hour <= 6
then
send push only
end
rule "Default"
when
true
then
send email retry 1
end
The Parser (Simplified)
public class DslParser {
public List<ParsedRule> parse(String dsl) {
List<ParsedRule> rules = new ArrayList<>();
// Real implementation would use ANTLR or similar
// Simplified: parse "when X then Y" blocks
Pattern rulePattern = Pattern.compile(
"rule \"(.+?)\"\\s+when\\s+(.+?)\\s+then\\s+(.+?)\\s+end",
Pattern.DOTALL
);
Matcher matcher = rulePattern.matcher(dsl);
while (matcher.find()) {
String name = matcher.group(1);
String condition = matcher.group(2).trim();
String action = matcher.group(3).trim();
rules.add(new ParsedRule(name, condition, action));
}
return rules;
}
}
public record ParsedRule(String name, String condition, String action) {}
The Executor
public class DslExecutor {
private final NotifierRegistry registry;
private final List<ParsedRule> rules;
public DslExecutor(NotifierRegistry registry, String dsl) {
this.registry = registry;
this.rules = new DslParser().parse(dsl);
}
public void execute(User user, String message, Context context) {
for (ParsedRule rule : rules) {
if (evaluateCondition(rule.condition(), user, context)) {
executeActions(rule.action(), user, message);
return;
}
}
}
private boolean evaluateCondition(String condition, User user, Context context) {
// Simplified condition evaluation
// Real implementation would build expression tree
return condition.equals("true") ||
(condition.contains("VIP") && user.getType().equals("VIP"));
}
private void executeActions(String actions, User user, String message) {
// Parse and execute each action
// "send sms retry 3" -> registry.create("sms"), apply retry decorator
System.out.println("Executing: " + actions + " for user: " + user.getName());
}
}
What Changed
| Rule Engine | DSL |
|---|---|
| Flat rules in tables | Structured rules with syntax |
| Limited condition logic | Full condition expressions |
| Single actions | Composed action sequences |
Pros
- Express complex business logic
- Domain experts can read/write rules
- Version control for business logic
Cons
- Must build/maintain parser
- Syntax errors are possible
- Steeper learning curve
When to Stop Here
- Business rules are complex (AND/OR, nesting, sequences)
- Domain experts need to author rules
- You’re building a product around configurability
The Evolution Ladder
If-Else
↓ "Same operation, different behavior"
Polymorphism
↓ "Same behavior, different types"
Generics
↓ "Remove factory switch"
Registry
↓ "Behavior as data"
Table-Driven
↓ "Decisions as data"
Rule Engine
↓ "Complex rules need syntax"
DSL
Each step:
- Reduces code churn for new features
- Increases flexibility
- Shifts change from code → data → language
Side-by-Side Comparison
| Stage | New Feature Requires | Who Can Add | Type Safety | Complexity |
|---|---|---|---|---|
| If-Else | Modify existing code | Developer | ✅ Full | Low |
| Polymorphism | New class | Developer | ✅ Full | Low |
| Generics | New class + types | Developer | ✅ Full | Medium |
| Registry | Register class | Developer | ⚠️ Partial | Medium |
| Table-Driven | Add config row | Ops/Dev | ❌ Runtime | Medium |
| Rule Engine | Add rule row | Product/Ops | ❌ Runtime | High |
| DSL | Write DSL rule | Domain Expert | ❌ Runtime | High |
Decision Guide
Is behavior truly varying by type?
│
No → Stay with If-Else (Stage 1)
│
Yes
│
Will you have 5+ implementations?
│
No → Polymorphism is enough (Stage 2)
│
Yes
│
Do implementations need different types?
│
No → Skip Generics
│
Yes → Add Generics (Stage 3)
│
↓
Do you need runtime selection by name?
│
No → Stay with DI / direct instantiation
│
Yes → Add Registry (Stage 4)
│
↓
Are implementations mostly same algorithm, different params?
│
No → Stay with Registry + Classes
│
Yes → Go Table-Driven (Stage 5)
│
↓
Do you need conditional business logic externalized?
│
No → Stay Table-Driven
│
Yes → Add Rule Engine (Stage 6)
│
↓
Are rules too complex for flat tables?
│
No → Stay with Rule Engine
│
Yes → Build DSL (Stage 7)
When NOT to Evolve
Each stage adds:
- Indirection
- Debugging complexity
- Learning curve
Stop evolving when:
| Signal | Action |
|---|---|
| Adding features is easy | Stay where you are |
| Team struggles to debug | Consider stepping back |
| Config changes cause outages | Add more validation, or use code |
| “We might need this” | Don’t evolve for hypotheticals |
The best architecture is the simplest one that solves your actual problem.
Real-World Examples
| System | What They Use | Why |
|---|---|---|
| Kubernetes | DSL (YAML manifests) | Declarative infrastructure |
| Spring | Registry + Annotations | Plugin architecture |
| Drools | Full Rule Engine + DSL | Complex business rules |
| Apache Camel | DSL (Route definitions) | Integration patterns |
| Stripe | Table-Driven | Webhook configurations |
| AWS IAM | Policy DSL (JSON) | Access control rules |
Final Takeaway
The Spectrum
Code-Driven ←——————————————————————→ Configuration-Driven
If-Else Polymorphism Registry Table Rules DSL
│ │ │ │ │ │
Everything Behavior Creation Params Logic Complex
in code abstracted dynamic in data in data rules
What This Blog Covers
This blog describes configuration-driven systems:
- Behavior controlled by authored rules
- Humans write the config
- Config is deployed explicitly
- Behavior is deterministic
This is different from data-driven systems:
- Behavior emerges from runtime data
- ML models make decisions
- Feedback loops optimize behavior
- Behavior adapts automatically
The typical evolution:
- Code-driven (if-else)
- Configuration-driven (tables, rules, DSL) ← This blog
- Data-driven (ML, optimization, real-time signals)
Most business systems stop at stage 2. Configuration-driven architectures are often a prerequisite for true data-driven systems.
The Insight
If-else is often a future interface, not a mistake.
Don’t abstract prematurely. Let patterns emerge from real code. When you see the same operation with different behaviors, that’s your signal to evolve.
Start simple. Evolve when it hurts. Stop when it works.