KISS — Keep It Simple #

There’s a paradox that almost always appears as an engineer gains experience: the deeper their understanding of patterns, abstractions, and architecture, the stronger the temptation to apply them everywhere. Code with many layers feels more “enterprise”. An interface for every dependency feels more “testable”. A generic function that handles every type at once feels more “reusable”. Yet, behind all that unnecessary complexity hides a real cost: longer onboarding time, bugs that are harder to trace, more expensive refactors, and a system that gets harder to understand every time a new engineer joins. KISS — Keep It Simple, Stupid — is the reminder that deliberate simplicity is the highest skill in software engineering, not a compromise. This guide covers what KISS really means, how to tell it apart from naive or half-finished code, the over-engineering signs to watch for, concrete examples from function level to architecture in Go and Dart, and when complexity is genuinely necessary and shouldn’t be simplified by force.

What Does KISS Mean? #

KISS stands for Keep It Simple, Stupid — sometimes interpreted as Keep It Simple and Straightforward. The principle is one: choose the simplest solution that genuinely solves the problem, without adding complexity that provides no real value.

But “simple” here doesn’t mean sloppy, no error handling, or ignoring edge cases. There’s an important difference between good simplicity, naivety, and unnecessary complexity:

SIMPLE (KISS ✓):                     NAIVE (not KISS ✗):
────────────────────────────────   ────────────────────────────────
Logic flow easy to follow           No error handling
Clear and explicit structure        Ignores important edge cases
Only the code needed                Unfinished code
Easy for other engineers            Only easy for its author

UNNECESSARY COMPLEXITY (✗):         JUSTIFIED COMPLEXITY (✓):
────────────────────────────────   ────────────────────────────────
Interface for one implementation    Interface because there are
that will never be swapped          several real, different implementations

Generic for types that are          Generic because there really are
known and won't change              multiple valid types

Abstraction layer that only         Abstraction layer that adds
forwards calls without              behavior: validation, transformation,
adding any value                    logging, caching

Factory + Builder for structs       Factory when object creation has
that can be instantiated directly   real conditional logic

The question to always ask before adding complexity: “Does this solve a real problem that exists right now?” If the answer is no, or “maybe someday” — that’s a sign to stay simple.

flowchart TD
    Q1{"Is there a real\nneed right now?"}
    Q2{"Does the simple solution\nfully solve it?"}
    Q3{"Does the complexity add\nclear value?"}
    SIMPLE["Use the simplest\nsolution ✓"]
    COMPLEX["Complexity\njustified ✓"]
    OVER["Over-engineering ✗\nReject this complexity"]

    Q1 -->|Yes| Q2
    Q1 -->|No| OVER
    Q2 -->|Yes| SIMPLE
    Q2 -->|No| Q3
    Q3 -->|Yes| COMPLEX
    Q3 -->|No| OVER

    style SIMPLE fill:#5CB85C,color:#fff
    style COMPLEX fill:#4C9BE8,color:#fff
    style OVER fill:#D9534F,color:#fff

Seven Signs of Over-Engineering #

Over-engineering is the most common and most dangerous KISS violation because it doesn’t look like a problem at first — it even looks like high quality. Here are the signs:

1. INTERNAL FRAMEWORK BIGGER THAN THE BUSINESS LOGIC
   More code supporting internal "infrastructure" (pipelines,
   registries, plugin systems) than the actual business logic.
   Signal: "before adding a new feature, you must register in 3 places first."

2. HARD TO EXPLAIN TO A NEW ENGINEER
   It takes more than 5 minutes to explain a flow that should be simple.
   Signal: "it's a bit complex, let me walk you through it step by step."

3. INTERFACE WITHOUT A SECOND IMPLEMENTATION
   An interface with only one concrete implementation and no concrete
   plan (not "maybe later") for another implementation.
   Signal: interface created "for extensibility" but nobody knows
   what extensibility means.

4. PASS-THROUGH LAYERS
   An abstraction layer that only forwards calls to the layer below
   without adding behavior, validation, or transformation.
   Signal: "why does a UseCase just call the Repository with no logic?"

5. GENERIC FOR A SINGLE TYPE
   A generic/template only ever used with one concrete type,
   with no plan to use other types.
   Signal: `Result[T]` always used as `Result[UserData]`.

6. EXTENSIBILITY WITHOUT A ROADMAP
   Config or plugin systems for features nobody has asked for,
   not on the roadmap, and no stakeholder requesting them.
   Signal: "let's make it generic now, we can extend later if needed."

7. VAGUE FUTURE FLEXIBILITY
   Code that's "flexible for the future" but nobody can explain
   which future, when, or who asked for it.
   Signal: "let's keep it abstract so it's easy to change later."

Complexity has a real cost that isn’t always visible in the first sprint: longer onboarding, bugs harder to trace because of indirection, more expensive refactoring because abstractions have spread, and code that gets harder to understand with every new engineer.


Clear Functions vs “Clever” Functions #

The most common KISS violation in code review is clever code — code that feels elegant to its author but confuses readers. Cleverness and clarity are two different things.

// ANTI-PATTERN: too many nested conditions, unclear types
// A new engineer must read every line to understand this function's contract
func IsAdult(user map[string]interface{}) bool {
    if age, ok := user["age"]; ok {
        if ageInt, ok := age.(int); ok {
            if ageInt >= 18 {
                return true
            } else {
                return false
            }
        }
    }
    return false
}

// CORRECT: explicit types, one-line logic, the contract is clear from the signature
type User struct {
    Age int
}

func (u User) IsAdult() bool {
    return u.Age >= 18
}

The KISS version is shorter, safer (types checked at compile time, not runtime), easier to test, and its contract is immediately clear from the signature without reading the implementation. Using map[string]interface{} for a struct whose shape is already known is over-generalization with zero benefit.

// ANTI-PATTERN: long chaining that makes debugging nearly impossible
// If there's an error, from which layer? Can't know without a debugger
func ReadConfig() (*Config, error) {
    return parse(validate(load(readFile("config.yaml"))))
}

// CORRECT: every step explicit with clear error context
func ReadConfig(path string) (*Config, error) {
    raw, err := readFile(path)
    if err != nil {
        return nil, fmt.Errorf("readConfig: read file: %w", err)
    }

    data, err := load(raw)
    if err != nil {
        return nil, fmt.Errorf("readConfig: load: %w", err)
    }

    cfg, err := parse(data)
    if err != nil {
        return nil, fmt.Errorf("readConfig: parse: %w", err)
    }

    if err := validate(cfg); err != nil {
        return nil, fmt.Errorf("readConfig: validate: %w", err)
    }

    return cfg, nil
}

The second version is longer, but when there’s an error in production, the message "readConfig: parse: unexpected token at line 12" points straight at the problem. The chained parse(validate(load(...))) only produces errors you can’t trace without breakpoints.


Guard Clauses — The Most Effective KISS Technique #

Nested conditions are one of the most common forms of hidden complexity, and the easiest to fix. A guard clause (early return) is the solution: validate all preconditions up front, return errors immediately, and let the main logic run at the top level without nesting.

// ANTI-PATTERN: deeply nested — a mental model that's very hard to build
// The engineer has to imagine all branches simultaneously
func ProcessOrder(order *Order) error {
    if order != nil {
        if order.UserID != "" {
            if order.Total > 0 {
                if order.Status == "pending" {
                    if err := validateItems(order.Items); err == nil {
                        // main logic buried at level 5
                        if err := saveOrder(order); err == nil {
                            return notifyUser(order.UserID)
                        } else {
                            return fmt.Errorf("save: %w", err)
                        }
                    } else {
                        return fmt.Errorf("items: %w", err)
                    }
                } else {
                    return errors.New("order not pending")
                }
            } else {
                return errors.New("invalid total")
            }
        } else {
            return errors.New("missing user id")
        }
    }
    return errors.New("order is nil")
}

// CORRECT: guard clauses — every line can be read independently
// The main logic sits at the bottom, not buried in nesting
func ProcessOrder(order *Order) error {
    if order == nil {
        return errors.New("order is nil")
    }
    if order.UserID == "" {
        return errors.New("missing user id")
    }
    if order.Total <= 0 {
        return errors.New("invalid total")
    }
    if order.Status != "pending" {
        return errors.New("order not pending")
    }
    if err := validateItems(order.Items); err != nil {
        return fmt.Errorf("validate items: %w", err)
    }

    // The main logic is at the top level — clear and easy to find
    if err := saveOrder(order); err != nil {
        return fmt.Errorf("save order: %w", err)
    }
    return notifyUser(order.UserID)
}
flowchart LR
    subgraph ANTI["Anti-Pattern: Nested"]
        direction TB
        A1["if order != nil"]
        A2["  if userID != ''"]
        A3["    if total > 0"]
        A4["      if status == pending"]
        A5["        → main logic (level 4)"]
        A1 --> A2 --> A3 --> A4 --> A5
    end

    subgraph GOOD["KISS: Guard Clauses"]
        direction TB
        G1["if order == nil → return error"]
        G2["if userID == '' → return error"]
        G3["if total <= 0 → return error"]
        G4["if status != pending → return error"]
        G5["→ main logic (level 0)"]
        G1 --> G2 --> G3 --> G4 --> G5
    end

    style A5 fill:#D9534F,color:#fff
    style G5 fill:#5CB85C,color:#fff

Rule of thumb: if a function has more than two levels of nesting, it can almost always be simplified with guard clauses. Every additional nesting level increases cognitive load exponentially — the reader has to hold all conditions in their head simultaneously.


Premature Abstraction #

Abstraction built before there’s a real need is the KISS violation most often committed by engineers who’ve read many design pattern books.

// ANTI-PATTERN: factory + interface for one implementation
// that will never be swapped
type PaymentProcessorFactory interface {
    Create(config PaymentConfig) PaymentProcessor
}

type PaymentProcessor interface {
    Process(ctx context.Context, req PaymentRequest) (*PaymentResult, error)
    Validate(req PaymentRequest) error
    Rollback(txID string) error
    GetFee(amount int64) float64
}

type DefaultPaymentProcessorFactory struct{}

func (f *DefaultPaymentProcessorFactory) Create(cfg PaymentConfig) PaymentProcessor {
    return &StripeProcessor{apiKey: cfg.APIKey}
}

// The service must go through a factory just to get a processor
// that's always Stripe — never anything else
type PaymentService struct {
    factory PaymentProcessorFactory // always DefaultPaymentProcessorFactory
}

func NewPaymentService(factory PaymentProcessorFactory) *PaymentService {
    return &PaymentService{factory: factory}
}

// Caller:
factory := &DefaultPaymentProcessorFactory{}
service := NewPaymentService(factory)
// 3 layers for something that could be 1 line

// CORRECT: direct and explicit, matching current needs
type PaymentService struct {
    stripeClient *stripe.Client
}

func NewPaymentService(stripeClient *stripe.Client) *PaymentService {
    return &PaymentService{stripeClient: stripeClient}
}

func (s *PaymentService) CreateCharge(ctx context.Context, amount int64, currency string) error {
    params := &stripe.ChargeParams{
        Amount:   stripe.Int64(amount),
        Currency: stripe.String(currency),
    }
    _, err := s.stripeClient.Charges.New(params)
    return err
}

// Later, WHEN there's a real need for multiple payment providers:
type PaymentGateway interface {
    Charge(ctx context.Context, amount int64, currency string) error
    Refund(ctx context.Context, chargeID string) error
}

// Only then does this abstraction have value — and the existing
// concrete implementation can be wrapped to satisfy the interface

The correct pattern is: start concrete, extract abstractions when a real need emerges. Not the other way around.


Explicit API Design vs Generic #

KISS is highly relevant in API design. Overly generic endpoints look “flexible” but are actually harder to use, harder to document, and harder to secure.

// ANTI-PATTERN: one generic endpoint for all operations
POST /api/v1/action
{
  "type": "create_user",
  "payload": { "name": "Budi", "email": "[email protected]" }
}

POST /api/v1/action
{
  "type": "update_user",
  "id": "usr-123",
  "payload": { "name": "Budi Santoso" }
}

POST /api/v1/action
{
  "type": "delete_user",
  "id": "usr-123"
}

Problems it creates:
  ✗ Can't use semantically correct HTTP methods (PUT, DELETE)
  ✗ Swagger/OpenAPI can't generate accurate documentation
  ✗ Per-resource-type authorization is hard — must parse the body first
  ✗ Rate limiting per operation type becomes complex
  ✗ Caching can't leverage HTTP semantics (GET is idempotent)
  ✗ All clients must know the valid "type" strings — not type-safe

// CORRECT: explicit endpoints matching resources and HTTP semantics
POST   /api/v1/users              → create a user (201 Created)
GET    /api/v1/users/:id          → get a user (200 OK, cacheable)
PUT    /api/v1/users/:id          → full user update (200 OK, idempotent)
PATCH  /api/v1/users/:id          → partial user update (200 OK)
DELETE /api/v1/users/:id          → delete a user (204 No Content, idempotent)

Benefits:
  ✓ OpenAPI docs automatically accurate per endpoint
  ✓ Authorization middleware can check per route + method
  ✓ Rate limiting per route + method
  ✓ GET requests cacheable in CDN and browser
  ✓ Auto-generated client SDKs are type-safe
  ✓ New engineers understand immediately without reading long docs
flowchart LR
    subgraph ANTI["Anti-Pattern: Generic Endpoint"]
        C1["POST /api/action\n{type: 'create_user'}"]
        C2["POST /api/action\n{type: 'update_user'}"]
        C3["POST /api/action\n{type: 'delete_user'}"]
    end

    subgraph GOOD["KISS: Explicit REST"]
        R1["POST /users"]
        R2["PUT /users/:id"]
        R3["DELETE /users/:id"]
    end

    style ANTI fill:#fff3cd
    style GOOD fill:#d4edda

KISS Error Handling #

Go philosophically applies KISS in error handling — errors are ordinary returned values, not hidden exceptions that can appear from anywhere. But wrong error handling patterns can still turn debugging into a nightmare.

// ANTI-PATTERN: excessive, redundant, uninformative error wrapping
func getUserData(id string) (*UserData, error) {
    user, err := repo.FindUser(id)
    if err != nil {
        // "error: error" is completely useless
        return nil, fmt.Errorf("error: %v", err)
    }

    profile, err := repo.FindProfile(user.ProfileID)
    if err != nil {
        // Too verbose and duplicated
        wrapped := fmt.Errorf("getUserData failed: profile fetch error: inner error: %v", err)
        return nil, wrapped
    }

    return buildUserData(user, profile), nil
}

// Resulting error message: "getUserData failed: profile fetch error: inner error: record not found"
// Redundant, hard to parse programmatically, doesn't use %w so
// it can't be unwrapped with errors.Is or errors.As

// CORRECT: wrap with clear, short, non-redundant context, using %w
func getUserData(ctx context.Context, id string) (*UserData, error) {
    user, err := repo.FindUser(ctx, id)
    if err != nil {
        return nil, fmt.Errorf("getUserData %s: find user: %w", id, err)
    }

    profile, err := repo.FindProfile(ctx, user.ProfileID)
    if err != nil {
        return nil, fmt.Errorf("getUserData %s: find profile: %w", id, err)
    }

    return buildUserData(user, profile), nil
}

// Resulting error message: "getUserData usr-123: find profile: record not found"
// Clear, non-redundant, unwrappable with errors.Is/errors.As
// A caller needing to check the error type:
if errors.Is(err, ErrNotFound) {
    return http.StatusNotFound
}

KISS error handling guidelines in Go:

// A consistent error wrapping format convention:
// "<function name> <identifier>: <operation>: %w"
fmt.Errorf("createOrder %s: save to db: %w", orderID, err)
fmt.Errorf("sendEmail %s: template render: %w", userEmail, err)
fmt.Errorf("processPayment %s: charge stripe: %w", paymentID, err)

// For functions without a meaningful identifier:
fmt.Errorf("validateConfig: missing required field 'db_host': %w", err)

// Don't create custom error types unless callers need to distinguish error types:
// Not needed:
type ValidationError struct{ Field string; Msg string }
// If the caller just logs and returns — use errors.New

// Custom error types needed when:
var ErrNotFound = errors.New("not found")
var ErrUnauthorized = errors.New("unauthorized")
// Because the caller does: if errors.Is(err, ErrNotFound) { ... }

KISS in Dart/Flutter #

In Flutter, the most common KISS violation is using heavyweight state management for state that could be handled much more simply.

// ANTI-PATTERN: BLoC for a counter used in only one widget
// 5 classes, 40+ lines for something that needs 5 lines

// events
abstract class CounterEvent {}
class IncrementEvent extends CounterEvent {}
class DecrementEvent extends CounterEvent {}
class ResetEvent extends CounterEvent {}

// state
class CounterState {
  final int count;
  const CounterState({required this.count});
  CounterState copyWith({int? count}) => CounterState(count: count ?? this.count);
}

// bloc
class CounterBloc extends Bloc<CounterEvent, CounterState> {
  CounterBloc() : super(const CounterState(count: 0)) {
    on<IncrementEvent>((e, emit) => emit(state.copyWith(count: state.count + 1)));
    on<DecrementEvent>((e, emit) => emit(state.copyWith(count: state.count - 1)));
    on<ResetEvent>((e, emit) => emit(const CounterState(count: 0)));
  }
}

// widget
class CounterWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return BlocBuilder<CounterBloc, CounterState>(
      builder: (context, state) {
        return Column(children: [
          Text('${state.count}'),
          ElevatedButton(
            onPressed: () => context.read<CounterBloc>().add(IncrementEvent()),
            child: const Text('+'),
          ),
        ]);
      },
    );
  }
}

// CORRECT: setState for local state that doesn't need sharing
// 1 class, 20 lines, immediately clear
class CounterWidget extends StatefulWidget {
  const CounterWidget({super.key});

  @override
  State<CounterWidget> createState() => _CounterWidgetState();
}

class _CounterWidgetState extends State<CounterWidget> {
  int _count = 0;

  @override
  Widget build(BuildContext context) {
    return Column(children: [
      Text('$_count', style: Theme.of(context).textTheme.headlineMedium),
      Row(mainAxisAlignment: MainAxisAlignment.center, children: [
        IconButton(
          onPressed: () => setState(() => _count--),
          icon: const Icon(Icons.remove),
        ),
        IconButton(
          onPressed: () => setState(() => _count++),
          icon: const Icon(Icons.add),
        ),
      ]),
    ]);
  }
}

KISS-based guidance for choosing Flutter state management:

setState           → local state, relevant only within one widget
                     doesn't need sharing with other widgets
                     doesn't need to persist after the widget is disposed

InheritedWidget    → state that needs sharing with direct descendants
/ Provider           without deep prop drilling, but not global

Riverpod / BLoC    → state that needs:
                     - sharing across many unrelated widgets
                     - persistence after the widget is disposed (caching)
                     - access from outside the widget tree (services, use cases)
                     - independent testing from the UI
BLoC, Riverpod, and Provider are very useful tools for the right use cases. The problem isn’t the tools — it’s when those heavyweight tools are used for state that setState could handle. That’s a KISS violation adding complexity without value.

KISS and Naming #

Overly abstract naming is the subtlest form of KISS violation — at first glance it looks like “clean code”, but it actually hides important information.

// ANTI-PATTERN: names that give no information about what they do
type UserManager struct{}      // manager of what, exactly?
type DataProcessor struct{}    // processes what kind of data?
type ServiceHandler struct{}   // handles which service?
type UtilHelper struct{}       // util for what?

func (m *UserManager) Do(user User) error {}         // Do what?
func (p *DataProcessor) Process(data interface{}) {} // produce what?

// CORRECT: names that reflect what they do in the domain language
type UserRegistrar struct{}        // clear: registers users
type ProfileUpdater struct{}       // clear: updates profiles
type OrderFulfiller struct{}       // clear: processes order fulfillment
type InvoiceGenerator struct{}     // clear: generates invoices

func (r *UserRegistrar) Register(req RegisterRequest) (*User, error) {}
func (g *InvoiceGenerator) GenerateForOrder(orderID string) (*Invoice, error) {}

// Variable names must also be clear:
// ✗
x := getUser(id)
tmp := calculateTotal(items)
result := process(data)

// ✓
currentUser := getUser(sessionID)
orderTotal := calculateTotal(order.Items)
parsedConfig := parseConfigFile(path)

Good names are documentation that can never go out of date. Overly abstract names force readers to open the implementation to understand the code that uses them — unnecessary cognitive load.


KISS’s Relationship with Other Principles #

KISS doesn’t stand alone. It interacts closely with other principles and often serves as the counterbalance when they’re applied excessively.

flowchart TD
    KISS["KISS\n(Keep It Simple)"]

    YAGNI["YAGNI\nDon't add what\nisn't needed yet"]
    DRY["DRY\nEliminate knowledge\nduplication"]
    SRP["SRP\nOne responsibility\nper component"]
    SOLID["SOLID\nExtendable\ndesign"]

    K_Y["KISS + YAGNI\n= Don't abstract\nbefore its time"]
    K_D["KISS + DRY\n= Abstract only\nthe same knowledge,\nnot the same shape"]
    K_S["KISS + SRP\n= Separate concerns,\nbut don't over-decompose\nuntil the flow is hard to follow"]
    K_SO["KISS + SOLID\n= Use patterns only\nwhen there's\na real need"]

    KISS --- YAGNI --> K_Y
    KISS --- DRY --> K_D
    KISS --- SRP --> K_S
    KISS --- SOLID --> K_SO

    style KISS fill:#4C9BE8,color:#fff
    style K_Y fill:#5CB85C,color:#fff
    style K_D fill:#5CB85C,color:#fff
    style K_S fill:#5CB85C,color:#fff
    style K_SO fill:#5CB85C,color:#fff

The most important relationship is KISS as the counterbalance to DRY. DRY pushes toward abstraction to eliminate duplication. KISS reminds us that a wrong abstraction costs more than duplication. Together they produce better decisions: abstract only when there’s real knowledge duplication, and the abstraction itself must be as simple as possible.


When Complexity Is Genuinely Justified #

KISS doesn’t mean always choosing the solution with the least code. There are situations where complexity is a fair — even mandatory — price to pay.

JUSTIFIED COMPLEXITY:
  ✓ Platforms or libraries used by many different teams
    → extensibility is a real need, not anticipation

  ✓ Domains that are inherently complex
    → rules engines, workflow engines, compilers, distributed systems
    → the complexity lives in the domain, not the technical solution

  ✓ Performance-critical code needing optimization
    → profiling shows this is a real bottleneck
    → not "might be slow"

  ✓ Security-critical code needing defense-in-depth
    → authentication, authorization, encryption
    → complexity here is a requirement, not a choice

  ✓ Backward compatibility requirements
    → public libraries that can't have breaking changes
    → APIs already consumed by many clients

AN APPROACH THAT STILL FOLLOWS KISS:
  → Start simple
  → Measure and validate real needs
  → Add complexity incrementally with clear justification
  → Document WHY this complexity is needed, not just what
Undocumented complexity is double technical debt. First, there’s the cost of understanding it. Second, nobody knows whether that complexity is still relevant or could be simplified. Always document why a complex design decision was made — not just what it does.

Anti-Patterns at a Glance #

// ✗ Nested ternaries that are hard to read
status := active ? (verified ? "active_verified" : "active_unverified") : "inactive"
// ✓ Explicit if-else, or a separate function with a clear name

// ✗ Long method chains that are hard to debug
result, err := service.Load(id).Filter(active).Transform(toDTO).Validate().Save(ctx)
// ✓ Store each intermediate result — inspectable, loggable, debuggable

// ✗ Interface for one implementation with no plan for another
type Logger interface { Log(msg string) }
type ConsoleLogger struct{}
// No other implementation, nothing mocked in tests
// ✓ Use *slog.Logger or *zap.Logger directly until there's a real need

// ✗ Config struct with too many fields for a simple use case
type ServerConfig struct {
    Host, Port, ReadTimeout, WriteTimeout, IdleTimeout,
    MaxHeaderBytes, MaxConns, KeepAlive, TLSCertFile, TLSKeyFile,
    // ... 20 more fields never set differently from their defaults
}
// ✓ Start with what's needed, add when there's a real need

// ✗ Names too abstract — no domain information
type Manager struct{}    // Manager of what?
type Handler struct{}    // Handles what?
type Processor struct{}  // Processes what?
// ✓ UserRegistrar, PaymentCharger, InvoiceGenerator — names that reflect the domain

// ✗ Pass-through layer without added value
type UserUseCase struct{ repo UserRepository }
func (u *UserUseCase) FindByID(id string) (*User, error) {
    return u.repo.FindByID(id) // just forwards the call, no logic
}
// ✓ Remove this layer; or add real logic (validation, transformation, caching)

// ✗ Generic too early
type Result[T any] struct{ Value T; Err error }
// If it's always used as Result[UserData] — there's no generic value
// ✓ Return (*UserData, error) directly — idiomatic Go

KISS Review Checklist #

FUNCTIONS AND METHODS:
  □ Every function describable in one sentence without the word "and"
  □ No nested conditions deeper than two levels — use guard clauses
  □ No long chaining that blocks debugging
  □ Names reflect what's done, not just the type or category

ABSTRACTION:
  □ Every interface has more than one real implementation
    (or at least is used for mocking in unit tests)
  □ No layer that only forwards calls without logic
  □ No generic type used with only one concrete type
  □ No abstractions for "maybe someday" needs

API AND CONTRACTS:
  □ Endpoints use semantically correct HTTP methods
  □ Input/output schemas clear and documented
  □ Error messages clear, non-redundant, traceable to their source

STATE MANAGEMENT (Flutter/Dart):
  □ setState only for single-widget local state
  □ Heavyweight state management only for state shared across widgets
  □ The state management choice can be explained with concrete reasons

COMPLEXITY:
  □ Every complex design decision has its reasoning documented
  □ No "future flexibility" without a concrete stakeholder requesting it
  □ A new engineer can understand the main flow in <15 minutes

Summary #

  • KISS isn’t naive or careless code — it’s code that’s clear, explicit, and no more complex than needed. The difference: simple still has proper error handling and correct edge cases; naive ignores them.
  • Seven over-engineering signs to watch: an internal framework bigger than the business logic, taking >5 minutes to explain a simple flow, interfaces without a second implementation, pass-through layers, generics for a single type, extensibility without a roadmap, and vague “future flexibility”.
  • Guard clauses are the most effective KISS technique: validate all preconditions up front with early returns, let the main logic run at the top level without nesting. Every additional nesting level increases cognitive load exponentially.
  • Premature abstraction is the most common KISS violation among experienced engineers. Start concrete, extract abstractions only when there’s a real need — not anticipation.
  • Explicit APIs are more KISS than generic endpoints: easier to document, secure per route, cache, and understand without reading long docs.
  • KISS error handling: wrap with short, non-redundant context, use %w so errors can be unwrapped, create custom error types only when callers need to distinguish error types.
  • In Flutter: setState for local state, BLoC/Riverpod for state that genuinely needs sharing. Choose based on real needs, not habit.
  • KISS + YAGNI prevents premature abstraction; KISS + DRY produces balance: abstract when there’s real knowledge duplication, not duplicated code shape.
  • Justified complexity does exist — public platforms, inherently complex domains, performance-critical, security-critical. But always document why the complexity is needed, and start simple before adding it.

← Previous: DRY   Next: YAGNI →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact