
From KISS and SOLID to Idempotency: Demystifying Classic Software Engineering Terms for Go Backend
- luandnh
- Golang , Backend , System design
- October 31, 2026
Table of Contents
If you have ever sat through a backend interview or a technical design session for a new service, you know the feeling: the room quickly descends into buzzword bingo. Acronyms fly from every direction: KISS, DRY, YAGNI, the five letters of SOLID, quickly followed by Idempotency, Backpressure, and Circuit Breaker.
These terms appear so frequently that engineers often wield them as rhetorical weapons. An engineer defending a ten-layer abstraction hierarchy will invoke Liskov Substitution. Another engineer pushing back against interface extraction will cite KISS or YAGNI.
Yet there is a massive chasm between reciting textbook definitions and writing code that survives a 2:00 AM production incident. Most classic software design principles crystallized during the golden age of pure object-oriented programming (C++, Java, C#) in the 1990s and 2000s. Golang, on the other hand, was born at Google out of sheer frustration with the sprawling inheritance trees, convoluted abstraction layers, and glacial compile times of those very OOP systems.
When you copy-paste traditional OOP dogmas into Go, you do not end up with “Clean Architecture.” What you get is an architectural mutant: Java written with Go syntax.
This article dissects the most common software engineering terms through Go’s pragmatic lens, examining where these principles hold true and where dogmatic adherence leads straight to over-engineering.
1. The Foundational Trio: KISS, DRY, and YAGNI
These three acronyms are usually taught as Day 1 programming principles. Paradoxically, they are also the most misunderstood and abused concepts in modern software design.
KISS (Keep It Simple, Stupid) and the “Clever Code” Trap
Rob Pike famously summarized Go’s philosophy: “Clear is better than clever.”
In Go, simplicity does not mean sloppy code, skipped error handling, or dumping 2,000 lines into a single main function. Simplicity here means explicitness. Simple code is code that an engineer on-call at midnight, groggy after being woken by a PagerDuty alert, can read top-to-bottom and immediately understand without jumping through eight different interface files in an IDE.
The most common trap for engineers coming from other ecosystems is trying to write “clever code.” Before Go 1.18 introduced generics, developers frequently attempted pseudo-generics using interface{} (now any) paired with the reflect package.
Warning
Every time you import the reflect package just to avoid writing a few explicit lines of struct inspection, you forfeit Go’s two greatest assets: compile-time type safety and runtime execution speed.
Consider two contrasting approaches to extracting merchant IDs from an order slice:
- Clever via Reflection
- Go KISS (Explicit & Fast)
// Tries to be clever: Generic Pluck using reflection
// Drawbacks: Runtime panics on mismatch, heavy heap allocations, 20-50x slower
func PluckIDs(slice any, fieldName string) ([]int64, error) {
val := reflect.ValueOf(slice)
if val.Kind() != reflect.Slice {
return nil, errors.New("input must be a slice")
}
var ids []int64
for i := 0; i < val.Len(); i++ {
item := val.Index(i)
field := item.FieldByName(fieldName)
if !field.IsValid() || field.Kind() != reflect.Int64 {
return nil, fmt.Errorf("invalid field %s", fieldName)
}
ids = append(ids, field.Int())
}
return ids, nil
}
// Explicit, type-safe, zero allocations beyond the destination slice
func ExtractMerchantIDs(orders []Order) []int64 {
if len(orders) == 0 {
return nil
}
ids := make([]int64, 0, len(orders))
for _, o := range orders {
ids = append(ids, o.MerchantID)
}
return ids
}
The first implementation looks reusable across all structs. But it silently fails at runtime if someone renames MerchantID to PartnerID, leaving the compiler powerless to help. The second approach requires dedicated functions if you need them for different types, but it is 100% type-safe, lightning-fast, and trivially readable.
DRY (Don’t Repeat Yourself) vs “A Little Copying”
DRY has mutated into one of the most toxic dogmas in engineering. Many developers develop an obsessive aversion to duplication: the moment three lines of code look identical across two packages, they rush to extract a shared function or create a pkg/utils dumping ground.
The result is predictable: a bloated utils package containing currency formatters, JWT decoders, GPS distance calculators, and HTTP retry wrappers. When a utility function changes, ten unrelated downstream services break.
Go Proverbs confronts this extreme directly:
“A little copying is better than a little dependency.”
In system architecture, you must distinguish between two forms of repetition:
- Essential Duplication: Two pieces of logic are identical because they express the same underlying invariant (e.g., SHA-256 hashing, tax computation formula). This logic should be unified.
- Accidental Duplication: Two pieces of logic happen to look identical today, but belong to separate business domains and will evolve independently over time.
A classic backend example is API DTOs: CreateMerchantRequest and UpdateMerchantRequest. Initially, both contain Name, Address, and Phone. If you merge them into a single struct purely for DRY compliance, you will suffer when update requirements evolve into partial PATCH operations while creation requires all fields. The shared struct accumulates awkward validation tags and convoluted conditional checks.
YAGNI (You Aren’t Gonna Need It)
YAGNI is the antidote to premature abstraction. Engineers routinely spend weeks constructing architectures that can:
- Swap PostgreSQL for MongoDB with a single config flag.
- Swap RabbitMQ for Kafka or NATS through a universal abstract message layer.
- Support dynamic runtime plugins for a feature that does not yet have 100 active users.
The reality: in 99% of cases, you will never swap your primary database. If you do migrate five years down the line, your access patterns and business domains will have changed so thoroughly that your original abstraction layer will be a liability, not an asset.
A practical heuristic: follow the Rule of Three. Write it directly the first time. Duplicate it the second time. Only when the exact same pattern emerges a third time should you pause and build an abstraction.
2. Re-evaluating SOLID in Go: No Classes, No Extends
Robert C. Martin formulated SOLID around class-based OOP (class Shape, class Circle extends Shape).
Go has no class keyword. Go has no extends keyword. Go does not support method overloading. What does SOLID actually mean in an idiomatic Go codebase?
graph TD
subgraph "Traditional OOP (Java / C#)"
ClassA[Parent Class] -->|extends inheritance| ClassB[Child Class]
InterfaceA[Fat Interface 20 methods] -.->|implements| ClassB
end
subgraph "Go Idiomatic (Composition & Duck Typing)"
StructA[Small Struct] -->|Embedding| StructB[Composite Struct]
StructB -.->|Implicit satisfaction| SmallInterface["Small Interface (1-2 methods)"]
end
S: Single Responsibility Principle (SRP)
At the code level, SRP suggests that a function or struct should have only one reason to change. In Go, the primary boundary of concern is the Package.
Engineers transitioning from Java often structure packages by technical layer:
controllers/services/models/repositories/
This structure forces the models package to hold every entity in the universe, inevitably triggering circular dependency headaches.
Idiomatic Go organizes code by Domain / Module Packages:
pkg/merchant/: Contains theMerchantentity, repository interface, and merchant domain logic.pkg/order/: Contains theOrderentity and order domain logic.
Each package owns a clear domain boundary. Opening pkg/order does not force you to sift through unrelated merchant billing code.
O: Open/Closed Principle (OCP)
The principle states: “Open for extension, closed for modification.” Where Java relies on class inheritance, Go uses Composition over Inheritance and the Functional Options Pattern.
Consider configuring a production gRPC client:
type Client struct {
endpoint string
timeout time.Duration
retries int
logger *slog.Logger
}
type Option func(*Client)
func WithTimeout(d time.Duration) Option {
return func(c *Client) {
c.timeout = d
}
}
func WithRetries(n int) Option {
return func(c *Client) {
c.retries = n
}
}
func NewClient(endpoint string, opts ...Option) *Client {
client := &Client{
endpoint: endpoint,
timeout: 5 * time.Second,
retries: 3,
logger: slog.Default(),
}
for _, opt := range opts {
opt(client)
}
return client
}
When you need to introduce metrics tracing (WithMetrics), you do not touch NewClient’s signature, you do not break 50 existing callers, and you avoid constructors like NewClientWithTimeoutAndRetriesAndMetrics. That is pure OCP in Go.
L: Liskov Substitution Principle (LSP)
LSP requires that subtypes must be substitutable for their base types without altering program correctness.
Without class inheritance, Go realizes LSP through Structural Typing (Duck Typing). A type satisfies an interface implicitly as long as it implements the required method signatures, with no implements declaration required.
However, LSP violations in Go typically lurk in behavioral contracts. If a struct implements io.Reader:
type Reader interface {
Read(p []byte) (n int, err error)
}
If your implementation reaches the end of the byte stream and returns nil, nil or panics instead of returning io.EOF, you have violated LSP. The code compiles without error, but any standard consumer expecting canonical io.Reader behavior will fall into an infinite loop.
I: Interface Segregation Principle (ISP)
This is where Go delivers its greatest design triumph.
The Go proverb states:
“The bigger the interface, the weaker the abstraction.”
Look at Go’s standard library. The most powerful interfaces in software history consist of a single method:
io.Reader:Read(p []byte) (n int, err error)io.Writer:Write(p []byte) (n int, err error)http.Handler:ServeHTTP(ResponseWriter, *Request)
Contrast this with the ubiquitous enterprise anti-pattern:
// ANTI-PATTERN: Monolithic enterprise interface
type MerchantRepository interface {
GetByID(ctx context.Context, id int64) (*Merchant, error)
GetByCode(ctx context.Context, code string) (*Merchant, error)
List(ctx context.Context, filter Filter) ([]*Merchant, error)
Create(ctx context.Context, m *Merchant) error
Update(ctx context.Context, m *Merchant) error
Delete(ctx context.Context, id int64) error
BatchUpdateStatus(ctx context.Context, ids []int64, status int) error
// ... 15 additional methods
}
The consequence: when you write a small helper function that merely verifies whether a store is currently open (CheckMerchantOpen), and that function takes MerchantRepository, you drag in dependencies on all 20 methods. When writing unit tests, you are forced to mock 20 methods just to test one query.
Go’s Golden Rule: Define interfaces at the consumer side, not the producer side.
// Clean: Order service defines only the contract it actually needs
type MerchantFinder interface {
GetByID(ctx context.Context, id int64) (*MerchantInfo, error)
}
type OrderService struct {
merchants MerchantFinder
}
D: Dependency Inversion Principle (DIP)
DIP states that high-level modules should not depend on low-level modules; both should depend on abstractions.
In Go, the guiding rule is:
“Accept interfaces, return structs.”
// Return concrete struct
func NewPostgresMerchantRepo(db *sql.DB) *PostgresMerchantRepo {
return &PostgresMerchantRepo{db: db}
}
// Accept minimal interface
func NewOrderService(merchants MerchantFinder, repo OrderRepository) *OrderService {
return &OrderService{
merchants: merchants,
repo: repo,
}
}
Why do experienced Go teams avoid reflection-heavy DI frameworks like Spring or Guice? Because manual dependency injection in main() or compile-time generators like Google’s wire offer complete clarity:
func main() {
db := initDB()
merchantRepo := repository.NewPostgresMerchantRepo(db)
orderRepo := repository.NewPostgresOrderRepo(db)
orderSvc := service.NewOrderService(merchantRepo, orderRepo)
handler := http.NewOrderHandler(orderSvc)
// Transparent, debuggable, traceable stack!
}
3. Distributed Systems & Production Engineering Terms
While design principles keep code readable, the following operational terms determine whether your backend survives high-throughput spikes and network failures.
Idempotency
In distributed networks, communication over HTTP or message queues is governed by a fundamental truth: the network is unreliable.
When a mobile client submits an order or charges a payment, a connection drop can occur precisely when the server commits the database transaction but before the HTTP response reaches the phone. The client assumes a network timeout and retries the request.
Without Idempotency, the customer gets charged twice for the same meal.
sequenceDiagram
autonumber
actor Client
participant Gateway as API Gateway
participant Redis as Redis (Deduplication)
participant OrderSvc as Order Service
participant DB as PostgreSQL
Client->>Gateway: POST /orders (Header: Idempotency-Key: abc-123)
Gateway->>Redis: SET order:idempotency:abc-123 LOCKED NX EX 60
alt Key exists (Duplicate retry)
Redis-->>Gateway: FALSE (Key already exists)
Gateway-->>Client: 409 Conflict or Cached Response
else New Key (First execution)
Redis-->>Gateway: OK
Gateway->>OrderSvc: CreateOrder()
OrderSvc->>DB: INSERT INTO orders ...
DB-->>OrderSvc: Order #999 created
OrderSvc->>Redis: SET order:idempotency:abc-123 RESULT EX 86400
OrderSvc-->>Client: 201 Created (Order #999)
end
Tip
Production Idempotency Blueprint:
- Require clients to generate a UUID for sensitive actions (
Idempotency-Key). - Use Redis
SET key value NX EX timeoutto establish a short-lived distributed lock against concurrent race conditions. - Enforce a unique database constraint (
order_ref_id UNIQUE) as the immutable barrier.
Backpressure and Concurrency Control
Goroutines are cheap: a new goroutine consumes roughly 2KB of initial stack space, compared to 1MB for an OS thread in Java or C++.
Because goroutines are lightweight, developers frequently write unbounded concurrency:
// ANTI-PATTERN: Unbounded concurrency suicide
func HandleWebhook(w http.ResponseWriter, r *http.Request) {
payload := parse(r)
// Dangerous: A 50,000 req/s spike spawns 50,000 concurrent goroutines
// hitting downstream databases simultaneously => pool exhaustion!
go processPayload(payload)
w.WriteHeader(http.StatusOK)
}
When downstream bottlenecks occur (database lock contention or slow third-party APIs), 50,000 goroutines queue up, consuming RAM, thrashing the Garbage Collector, and eventually provoking an Out-Of-Memory (OOM) crash.
To protect the system, implement Backpressure and Worker Pools:
type JobQueue struct {
jobs chan Job
}
func NewJobQueue(workers int, queueSize int) *JobQueue {
q := &JobQueue{
jobs: make(chan Job, queueSize),
}
for i := 0; i < workers; i++ {
go q.worker()
}
return q
}
func (q *JobQueue) Submit(job Job) bool {
select {
case q.jobs <- job:
return true // Successfully queued
default:
return false // Queue full => Return 429 or 503 (Backpressure)
}
}
Circuit Breaker & Fail-Fast
The Fail-Fast principle demands: if a request is doomed to fail, fail it immediately at the gateway. Do not tie up CPU cycles, goroutines, or database connections trying to process bad inputs. Is the JWT expired? Are mandatory request fields missing? Reject with HTTP 400/401 at the middleware layer.
When an external microservice (like a payment gateway) suffers an outage, downstream calls hang until reaching their client timeout (e.g., 10 seconds). Thousands of stalled requests consume all available connections, triggering Cascading Failure across your entire infrastructure.
A Circuit Breaker halts the domino effect:
- Closed: Normal operation, requests pass through.
- Open: When error rates cross a threshold (e.g., 50% failures over 10 seconds), the circuit trips open. Subsequent requests fail immediately (
Fail-Fast) without hitting the remote API. - Half-Open: After a cooldown window, a small sample of canary requests is permitted. If they succeed, the circuit closes; if they fail, the breaker stays open.
In Go, lightweight packages like sony/gobreaker solve this elegantly in-process without needing heavyweight service mesh sidecars.
Graceful Shutdown
In cloud and Kubernetes environments, containers terminate and reschedule constantly. When Kubernetes dispatches a SIGTERM signal, an unhandled immediate exit severs in-flight HTTP requests mid-stream, yielding 502 Bad Gateway errors on ingress proxies.
Every production Go backend requires an explicit Graceful Shutdown:
func main() {
server := &http.Server{Addr: ":8080", Handler: router}
go func() {
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
slog.Error("server error", "error", err)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
slog.Info("Shutting down server gracefully...")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
slog.Error("Server forced to shutdown:", "error", err)
}
db.Close()
slog.Info("Server exited cleanly.")
}
4. The Cheatsheet: Textbook OOP vs Idiomatic Go
| Principle | Classic OOP View | Pragmatic Go Interpretation |
|---|---|---|
| KISS | Write code without architectural consideration. | Explicitness. Prefer clear, readable code over clever reflection wizardry. |
| DRY | Extract every repeated line into shared packages. | Accept accidental duplication. “A little copying is better than a little dependency.” |
| YAGNI | Build abstract database layers in case DB changes later. | Only abstract on the third repetition (Rule of Three). |
| SRP | Single-purpose classes resulting in dozens of micro-files. | Package Cohesion. Group by business domain, not architectural layer. |
| OCP | Extend features through subclass inheritance. | Composition, Struct Embedding, and Functional Options. |
| ISP | Deep interface hierarchies with extensive method lists. | Minimalist interfaces (1-2 methods) defined by the Consumer. |
| DIP | Magic reflection-based dependency injection containers. | “Accept interfaces, return structs”. Wire via explicit constructors. |
| Idempotency | Assumed client responsibility. | Core backend invariant. Enforced via Idempotency-Key and distributed locks. |
| Fail-Fast | Catch-all exceptions at outer boundaries. | Validate immediately at the edge; isolate external failures via Circuit Breakers. |
Summary: Pragmatism Above Dogma
Principles and design patterns exist to provide a shared engineering vocabulary, not to serve as rigid dogmas for judging code.
The enduring appeal of Golang is its pragmatism. Go was never intended to win awards for “most expressive syntax” or “most sophisticated meta-programming.” It was designed to solve large-scale engineering problems: millions of lines of code, thousands of contributing engineers, compiling in seconds, and running reliably for months without restarting.
Before you introduce another interface or layer of abstraction, pause for ten seconds and ask: does this code solve an operational problem, or does it merely satisfy the engineer’s ego?


