36.7 Table-Driven Design in Application Logic
Right, let’s talk about table-driven design. You’ve probably already used this pattern without knowing its fancy name. It’s the moment you realize you’re about to write your fifth if or case statement for what is essentially the same operation, just on different data, and you scream “there has to be a better way!”
There is. It’s called table-driven design, and it’s embarrassingly simple. Instead of a sprawling chain of conditional logic, you define your behavior in a data structure—a table—and then you write one, single, elegant loop to process it. The logic is decoupled from the data, which means your code becomes more readable, more testable, and infinitely easier to change and extend. It’s the difference between hand-carving each piece of a model and using a 3D printer. One is artisanally painful; the other is brilliantly efficient.
Why This Isn’t Just for Unit Tests
Most introductions to table-driven design use it for testing, and it’s fantastic for that. But limiting it to tests is like only using your Swiss Army knife to open bottles. Let’s use it where the logic actually lives: in your application.
Imagine you’re building a simple calculator microservice. The classic, clunky switch statement approach looks like this:
func Calculate(a, b int, operation string) (int, error) {
switch operation {
case "add":
return a + b, nil
case "subtract":
return a - b, nil
case "multiply":
return a * b, nil
case "divide":
if b == 0 {
return 0, errors.New("division by zero")
}
return a / b, nil
default:
return 0, errors.New("unknown operation")
}
}
This works. It’s also a maintenance nightmare waiting to happen. Adding a new operation, say modulus, means you have to dive into this function, find the right place in the switch, and add another case. It’s a small change that touches core logic. Now, let’s refactor this into a table-driven design.
Building the Lookup Table
The core idea is to define our operations as data. We’ll create a map where the key is the operation string and the value is a function that performs the calculation.
// Define a type for our operation function for clarity.
type operationFunc func(int, int) (int, error)
var operations = map[string]operationFunc{
"add": func(a, b int) (int, error) { return a + b, nil },
"subtract": func(a, b int) (int, error) { return a - b, nil },
"multiply": func(a, b int) (int, error) { return a * b, nil },
"divide": func(a, b int) (int, error) {
if b == 0 {
return 0, errors.New("division by zero")
}
return a / b, nil
},
}
Now, our Calculate function becomes a sleek lookup and execution machine:
func Calculate(a, b int, op string) (int, error) {
if operation, exists := operations[op]; exists {
return operation(a, b)
}
return 0, errors.New("unknown operation")
}
See what happened? The cyclomatic complexity of Calculate just plummeted. The gnarly conditional tree is gone, replaced by a simple map lookup. The logic for each operation is now neatly encapsulated and isolated. To add the modulus operation, you don’t touch the Calculate function at all. You just add a new entry to the operations map. This is the Open/Closed Principle in action: open for extension, closed for modification.
Handling More Complex State and Preconditions
“But my logic isn’t just a simple function call!” I hear you protest. “I need to check permissions, validate state, all that jazz!” No problem. The table holds any data you need. Let’s level up.
Say your operations now require different user roles. Instead of a simple function, your table entry can be a struct holding all the necessary metadata and logic.
type Operation struct {
Function operationFunc
RequiredRole string // e.g., "user", "admin"
}
var advancedOperations = map[string]Operation{
"add": {
Function: func(a, b int) (int, error) { return a + b, nil },
RequiredRole: "user",
},
"delete": {
Function: func(a, b int) (int, error) { return a / b, nil }, // let's pretend this is a delete
RequiredRole: "admin",
},
}
func CalculateAdvanced(a, b int, op string, userRole string) (int, error) {
operation, exists := advancedOperations[op]
if !exists {
return 0, errors.New("unknown operation")
}
if userRole != operation.RequiredRole {
return 0, errors.New("insufficient permissions")
}
return operation.Function(a, b)
}
The pattern scales beautifully. The table isn’t just for functions; it’s for any data that drives your application’s behavior.
The One Major Pitfall: Initialization Order
Here’s the catch that every Go developer hits at least once: package initialization order. If you define your table as a global var, you can’t populate it using other functions that might also rely on global state or complex initialization. You’ll get nil pointer panics that are a nightmare to debug.
The solution is often to initialize your table in an init() function or, even better, to eschew globals altogether and provide a constructor that builds the table.
func NewCalculator() map[string]operationFunc {
return map[string]operationFunc{
"add": func(a, b int) (int, error) { return a + b, nil },
// ... other ops
}
}
This makes dependencies explicit and is far easier to test. You can pass in mock operations or configure them based on external settings. Global tables are convenient, but like a free weekend in Vegas, the convenience can come with hidden costs.
The bottom line is this: whenever you see a set of related if/case statements, your spidey-sense should tingle. Ask yourself, “Could this be a table?” Nine times out of ten, the answer is a resounding yes, and your code will be cleaner, simpler, and more robust for it.