45.2 Table-Driven Design: Data as Code
Right, let’s talk about one of the most embarrassingly simple yet profoundly powerful ideas in software design: table-driven development. You’ve probably written a function with a gnarly switch or a chain of if/else if statements that made you feel a little dirty afterward. You know the type:
func GetSound(animal string) string {
if animal == "dog" {
return "woof"
} else if animal == "cat" {
return "meow"
} else if animal == "cow" {
return "moo"
} else if animal == "duck" {
return "quack"
} // ... and so on for 20 more animals
return "what did you just call me?"
}
This code is verbose, brittle, and a pain to test or extend. It screams “I was written at 4:55 PM on a Friday.” The table-driven approach looks at this mess and asks a better question: “What if the data was the code?”
The Core Idea: A Slice and a Map Walk Into a Bar…
The fundamental shift is from imperative logic ("do this, then do that") to declarative data ("here is the mapping"). Instead of telling the machine how to find the answer step-by-step, you just give it the answer key.
The most common way to do this in Go is with a map. The above travesty becomes:
var animalSound = map[string]string{
"dog": "woof",
"cat": "meow",
"cow": "moo",
"duck": "quack",
}
func GetSound(animal string) string {
if sound, ok := animalSound[animal]; ok {
return sound
}
return "what did you just call me?"
}
Look at that. The GetSound function is now five lines of idiot-proof code. The complexity is entirely in the data structure, which is exactly where it should be. Adding a new animal doesn’t require touching the function’s logic; you just add a new key-value pair to the map. This is a principle you’ll see everywhere in good Go code: separate the mechanism (the function) from the policy (the data).
Leveling Up: When a Simple Map Isn’t Enough
Maps are great for lookups, but what if your logic needs more data or even behavior? This is where you graduate to a slice of structs. Imagine you’re configuring HTTP routes. A simple map won’t cut it; you need a path, a handler function, and maybe a method.
var routes = []struct {
method string
path string
handler http.HandlerFunc
}{
{"GET", "/api/users", getUserHandler},
{"POST", "/api/users", createUserHandler},
{"GET", "/api/posts", getPostHandler},
}
// Later, during server setup, you just range over the table
for _, route := range routes {
http.HandleFunc(route.path, route.handler)
// In a real router, you'd use the 'method' too
}
Now you have a single, authoritative source of truth for your routes. It’s self-documenting, easy to modify, and trivial to validate or generate. This pattern is why many Go web frameworks are just glorified table-driven routers at their core.
The Testing Superpower
This is where the approach truly sings. Testing the GetSound function from our first example is a nightmare. You have to test every branch. Testing the table-driven version? You test the function once to ensure the lookup works, and then your tests focus on validating the data table itself.
func TestAnimalSounds(t *testing.T) {
// Test known values
if got := GetSound("duck"); got != "quack" {
t.Errorf("Expected 'quack', got '%s'", got)
}
// Test unknown value
if got := GetSound("platypus"); got != "what did you just call me?" {
t.Errorf("Unexpected sound for platypus: '%s'", got)
}
}
// But the real test might be ensuring the table is complete and correct.
// This is often simpler and more comprehensive.
Common Pitfalls and The One Big ‘Gotcha’
The biggest mistake is over-engineering the table. I’ve seen junior developers create a “table” that’s actually a complex function generating a map on init, which defeats the entire purpose of simplicity. Keep the table static and declarative.
The main ‘gotcha’ is initialization order. Your table is a global variable, and the things you put in it (like function references) must be initialized after those functions are defined. If you define your table at the top of a file with var myTable = ... and your handler functions are below it, the compiler will rightly complain that it’s using undefined identifiers. The fix is simple: use a function to initialize the table (like func init() {...}) or define your functions first. It’s a small price to pay for the immense clarity you gain.
So the next time your finger hovers over the switch keyword, stop. Ask yourself: “Could this be a map?” You’ll be surprised how often the answer is a resounding yes. It makes your code simpler, more robust, and a hell of a lot easier to read. And your teammates, who have to review your PRs, will thank you for it.