DRY — Don’t Repeat Yourself #

There’s one habit almost every engineer has done: copying a block of code from one place to another because “the logic is the same, just needs a small tweak”. It feels productive in the first sprint. By the tenth sprint, when a business rule changes and you have to find all those copies one by one — that’s when the cost hits. DRY (Don’t Repeat Yourself) is the principle that attacks this problem at its root: every piece of knowledge in a system must have a single, unambiguous, authoritative representation. This article covers what DRY really means, how to distinguish dangerous duplication from coincidental similarity, the violation patterns most common in real codebases, refactoring techniques from function level to architecture, and — just as importantly — when DRY itself becomes a trap.

What Does “Repeat” Really Mean? #

DRY is often misunderstood as a ban on having two identical lines of code. The more accurate definition goes deeper: what’s prohibited is duplicating knowledge — the same business rule, logic, or design decision represented in more than one place.

This distinction matters because two blocks of code that look identical don’t always represent the same knowledge. And conversely, duplicated knowledge can hide behind code that looks different.

// These two functions look syntactically identical
func calculateOrderTax(price float64) float64 {
    return price * 0.11 // rule: 11% VAT for orders
}

func calculateShippingTax(price float64) float64 {
    return price * 0.11 // rule: 11% VAT for shipping
}

// Is this a DRY violation?
// Depends: do order VAT and shipping VAT rules ALWAYS move together?
// If yes → this is knowledge duplication, needs to be merged
// If no → these are two different rules that happen to have the same value now

The question to answer before abstracting: “If this rule changes in one place, will it definitely change in the other place too?” If the answer is yes — it’s knowledge duplication that DRY should resolve.

flowchart TD
    Q1{"Two blocks of code\nlook the same?"}
    Q2{"Represent the\nsame knowledge?"}
    Q3{"If one changes,\nmust the other\nchange too?"}
    AB["Abstract into\na single source\n✓ DRY"]
    LEAVE["Keep them separate\n✓ Also correct"]

    Q1 -->|Yes| Q2
    Q1 -->|No| LEAVE
    Q2 -->|Yes| AB
    Q2 -->|Not sure| Q3
    Q3 -->|Yes| AB
    Q3 -->|No| LEAVE

    style AB fill:#5CB85C,color:#fff
    style LEAVE fill:#4C9BE8,color:#fff

Common DRY Violation Patterns #

DRY violations appear in many forms. Some are easy to spot in code review, others hide behind architectural layers.

Business Logic Duplication #

The most dangerous form because its impact is the widest. Business rules scattered across many places will almost certainly drift out of sync over time.

// ANTI-PATTERN: the "user must be 18" rule lives in two places
// with different error messages too

func RegisterUser(req RegisterRequest) error {
    if req.Age < 18 {
        return errors.New("user must be at least 18 years old")
    }
    // ... registration flow
    return nil
}

func UpdateProfile(req UpdateProfileRequest) error {
    if req.Age < 18 {
        return errors.New("age is not valid") // ← different message!
    }
    // ... update flow
    return nil
}

// Problem: product decides the minimum age rises to 21.
// Who remembers the same validation exists in UpdateProfile?
// And why are the messages different — a latent bug already?

// CORRECT: one source of truth for the age rule
const MinimumUserAge = 18

func validateAge(age int) error {
    if age < MinimumUserAge {
        return fmt.Errorf("minimum age is %d years old", MinimumUserAge)
    }
    return nil
}

func RegisterUser(req RegisterRequest) error {
    if err := validateAge(req.Age); err != nil {
        return err
    }
    return nil
}

func UpdateProfile(req UpdateProfileRequest) error {
    if err := validateAge(req.Age); err != nil {
        return err
    }
    return nil
}
// Rule changed? Change MinimumUserAge in one place. Done.

Duplicated Constants and Magic Numbers #

Scattered magic numbers are the easiest duplication to miss in code review but the most expensive during maintenance.

// ANTI-PATTERN: the numbers 3, 5, and 30 are scattered without explanation

func processOrder(order Order) {
    if order.RetryCount > 3 {  // ← what is this 3?
        markAsFailed(order)
    }
}

func processPayment(payment Payment) {
    if payment.Attempts > 3 {  // ← the same 3? or a different rule?
        refund(payment)
    }
}

func cleanupSessions() {
    threshold := time.Now().Add(-30 * 24 * time.Hour) // ← 30 days hardcoded
    db.Where("created_at < ?", threshold).Delete(&Session{})
}

func archiveOldLogs() {
    cutoff := time.Now().AddDate(0, 0, -30) // ← 30 days again here
    // Is this the same 30 days as session cleanup, or different?
}

// CORRECT: well-documented constants
const (
    MaxOrderRetryCount    = 3
    MaxPaymentAttempts    = 3
    SessionRetentionDays  = 30
    LogArchiveDays        = 30
)

func processOrder(order Order) {
    if order.RetryCount > MaxOrderRetryCount {
        markAsFailed(order)
    }
}

func processPayment(payment Payment) {
    if payment.Attempts > MaxPaymentAttempts {
        refund(payment)
    }
}

func cleanupSessions() {
    threshold := time.Now().AddDate(0, 0, -SessionRetentionDays)
    db.Where("created_at < ?", threshold).Delete(&Session{})
}

Cross-Layer Validation Duplication #

This is the most debated DRY violation. Common in web applications: the same validation runs in the HTTP handler, the service, and the repository.

// ANTI-PATTERN: email validation runs three times across three layers
// with different implementations

// HTTP Handler Layer
func CreateUserHandler(w http.ResponseWriter, r *http.Request) {
    var req CreateUserRequest
    json.NewDecoder(r.Body).Decode(&req)

    if req.Email == "" || !strings.Contains(req.Email, "@") {
        http.Error(w, "invalid email", 400)
        return
    }
    userService.Create(req)
}

// Service Layer
func (s *UserService) Create(req CreateUserRequest) error {
    if req.Email == "" {
        return errors.New("email required") // validates again, but weaker
    }
    return s.repo.Save(req)
}

// Repository Layer
func (r *UserRepository) Save(req CreateUserRequest) error {
    if req.Email == "" { // validates again in the lowest layer
        return errors.New("cannot save user without email")
    }
    // ...
}
// CORRECT: validation lives in one right place
// Handler: validates input format (parsing, data types)
// Service: validates business rules (email already registered, blocked domain)
// Repository: does no validation — that's not its responsibility

// One validator package usable anywhere
package validator

import (
    "errors"
    "regexp"
)

var emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`)

func ValidateEmail(email string) error {
    if email == "" {
        return errors.New("email is required")
    }
    if !emailRegex.MatchString(email) {
        return errors.New("invalid email format")
    }
    return nil
}

// Handler: use the validator to check format
func CreateUserHandler(w http.ResponseWriter, r *http.Request) {
    var req CreateUserRequest
    json.NewDecoder(r.Body).Decode(&req)

    if err := validator.ValidateEmail(req.Email); err != nil {
        http.Error(w, err.Error(), 400)
        return
    }
    userService.Create(req)
}

// Service: focuses on business rules, not format validation
func (s *UserService) Create(req CreateUserRequest) error {
    existing, _ := s.repo.FindByEmail(req.Email)
    if existing != nil {
        return errors.New("email already registered")
    }
    return s.repo.Save(req)
}

Architecture-Level Duplication #

The subtlest form: two different services independently implementing identical logic because there’s no shared library or shared domain model.

// ANTI-PATTERN: OrderService and InvoiceService both
// implement tax calculation separately

// order/service.go
func (s *OrderService) calculateTotal(items []OrderItem) float64 {
    subtotal := 0.0
    for _, item := range items {
        subtotal += item.Price * float64(item.Quantity)
    }
    tax := subtotal * 0.11
    return subtotal + tax
}

// invoice/service.go
func (s *InvoiceService) computeAmount(lines []InvoiceLine) float64 {
    base := 0.0
    for _, line := range lines {
        base += line.UnitPrice * float64(line.Qty)
    }
    vat := base * 0.11 // ← exactly the same, but not connected
    return base + vat
}

// CORRECT: tax logic lives in one shared domain model

// pricing/tax.go — one source of truth for VAT calculation
package pricing

const VATRate = 0.11

type TaxCalculator struct{}

func (tc TaxCalculator) Apply(subtotal float64) TaxResult {
    tax := subtotal * VATRate
    return TaxResult{
        Subtotal: subtotal,
        Tax:      tax,
        Total:    subtotal + tax,
    }
}

type TaxResult struct {
    Subtotal float64
    Tax      float64
    Total    float64
}

// order/service.go uses TaxCalculator
func (s *OrderService) calculateTotal(items []OrderItem) pricing.TaxResult {
    subtotal := 0.0
    for _, item := range items {
        subtotal += item.Price * float64(item.Quantity)
    }
    return s.taxCalc.Apply(subtotal)
}

// invoice/service.go uses the same TaxCalculator
func (s *InvoiceService) computeAmount(lines []InvoiceLine) pricing.TaxResult {
    base := 0.0
    for _, line := range lines {
        base += line.UnitPrice * float64(line.Qty)
    }
    return s.taxCalc.Apply(base)
}

DRY at the Function and Method Level #

The most basic level of applying DRY is extracting repeated logic into its own function or method. This isn’t just about avoiding copy-paste — it’s about giving a domain concept a name.

// ANTI-PATTERN: pagination logic reimplemented in every handler

func ListOrdersHandler(w http.ResponseWriter, r *http.Request) {
    page, _ := strconv.Atoi(r.URL.Query().Get("page"))
    if page <= 0 {
        page = 1
    }
    limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
    if limit <= 0 || limit > 100 {
        limit = 10
    }
    offset := (page - 1) * limit
    orders := orderRepo.Find(offset, limit)
    json.NewEncoder(w).Encode(orders)
}

func ListProductsHandler(w http.ResponseWriter, r *http.Request) {
    page, _ := strconv.Atoi(r.URL.Query().Get("page"))
    if page <= 0 {
        page = 1 // ← copy-paste from the previous handler
    }
    limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
    if limit <= 0 || limit > 100 { // ← and this
        limit = 10
    }
    offset := (page - 1) * limit
    products := productRepo.Find(offset, limit)
    json.NewEncoder(w).Encode(products)
}

// CORRECT: extract into a function whose name reflects the domain concept
type Pagination struct {
    Page   int
    Limit  int
    Offset int
}

const (
    defaultPage  = 1
    defaultLimit = 10
    maxLimit      = 100
)

func parsePagination(r *http.Request) Pagination {
    page, _ := strconv.Atoi(r.URL.Query().Get("page"))
    if page <= 0 {
        page = defaultPage
    }

    limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
    switch {
    case limit <= 0:
        limit = defaultLimit
    case limit > maxLimit:
        limit = maxLimit
    }

    return Pagination{
        Page:   page,
        Limit:  limit,
        Offset: (page - 1) * limit,
    }
}

func ListOrdersHandler(w http.ResponseWriter, r *http.Request) {
    p := parsePagination(r) // one line, clear intent
    orders := orderRepo.Find(p.Offset, p.Limit)
    json.NewEncoder(w).Encode(orders)
}

func ListProductsHandler(w http.ResponseWriter, r *http.Request) {
    p := parsePagination(r)
    products := productRepo.Find(p.Offset, p.Limit)
    json.NewEncoder(w).Encode(products)
}

Notice: by naming it parsePagination, you’re not just removing duplication — you’re also documenting that “this is pagination logic”, not just “a few lines that do something with page and limit”.


DRY at the Struct and Encapsulation Level #

The idiomatic Go way to apply DRY at the domain level is attaching knowledge to the most relevant struct through methods.

// ANTI-PATTERN: knowledge about Order lives outside the Order struct itself
type Order struct {
    Items    []OrderItem
    Discount float64
    Status   string
}

// In a service:
func processOrder(order Order) {
    subtotal := 0.0
    for _, item := range order.Items {
        subtotal += item.Price * float64(item.Quantity)
    }
    total := subtotal - order.Discount // ← Order logic outside Order

    if order.Status != "pending" && order.Status != "draft" { // ← also outside Order
        return errors.New("order cannot be processed")
    }
    // ...
}

// In another handler (duplication):
func cancelOrder(order Order) error {
    if order.Status != "pending" { // ← similar validation, but not identical
        return errors.New("only pending orders can be cancelled")
    }
    // ...
}

// CORRECT: knowledge about Order lives on Order
type Order struct {
    Items    []OrderItem
    Discount float64
    Status   string
}

func (o Order) Subtotal() float64 {
    total := 0.0
    for _, item := range o.Items {
        total += item.Price * float64(item.Quantity)
    }
    return total
}

func (o Order) Total() float64 {
    return o.Subtotal() - o.Discount
}

func (o Order) IsProcessable() bool {
    return o.Status == "pending" || o.Status == "draft"
}

func (o Order) IsCancellable() bool {
    return o.Status == "pending"
}

// The service now speaks the domain language instead of repeating logic
func processOrder(order Order) error {
    if !order.IsProcessable() {
        return errors.New("order cannot be processed in current status")
    }
    total := order.Total() // one call, knowledge lives in Order
    chargePayment(total)
    return nil
}

func cancelOrder(order Order) error {
    if !order.IsCancellable() {
        return errors.New("only pending orders can be cancelled")
    }
    return nil
}

DRY at the Configuration Level #

Scattered configuration is one of the most common DRY violations in production applications — and often the cause of environment-specific bugs that are hard to reproduce.

// ANTI-PATTERN: hardcoded config values scattered around
func connectDB() *sql.DB {
    db, _ := sql.Open("postgres", "host=localhost port=5432 ...") // ← hardcoded
    db.SetMaxOpenConns(10)   // ← magic number
    db.SetMaxIdleConns(5)    // ← magic number
    return db
}

func connectRedis() *redis.Client {
    return redis.NewClient(&redis.Options{
        Addr:     "localhost:6379", // ← hardcoded again
        PoolSize: 10,               // ← same as DB? different rule?
    })
}

func main() {
    if os.Getenv("ENV") == "production" { // ← env checks scattered
        // setup production logger
    }
}

// CORRECT: config read once from one source, shared by everything
type Config struct {
    Database DatabaseConfig
    Redis    RedisConfig
    App      AppConfig
}

type DatabaseConfig struct {
    DSN          string `env:"DB_DSN"`
    MaxOpenConns int    `env:"DB_MAX_OPEN_CONNS" envDefault:"10"`
    MaxIdleConns int    `env:"DB_MAX_IDLE_CONNS" envDefault:"5"`
}

type RedisConfig struct {
    Addr     string `env:"REDIS_ADDR" envDefault:"localhost:6379"`
    PoolSize int    `env:"REDIS_POOL_SIZE" envDefault:"10"`
}

type AppConfig struct {
    Env  string `env:"APP_ENV" envDefault:"development"`
    Port int    `env:"APP_PORT" envDefault:"8080"`
}

func LoadConfig() (*Config, error) {
    cfg := &Config{}
    if err := env.Parse(cfg); err != nil {
        return nil, fmt.Errorf("failed to load config: %w", err)
    }
    return cfg, nil
}

// main.go: config read once, passed to all components
func main() {
    cfg, err := LoadConfig()
    if err != nil {
        log.Fatal(err)
    }

    db := connectDB(cfg.Database)
    redisClient := connectRedis(cfg.Redis)
    server := NewServer(cfg.App, db, redisClient)
    server.Run()
}
flowchart TD
    ENV["Environment Variables\n/ Config File"]
    CFG["Config Struct\n(single source of truth)"]
    DB["Database\nConnection"]
    REDIS["Redis\nConnection"]
    SERVER["HTTP Server"]
    WORKER["Background\nWorker"]

    ENV -->|read once| CFG
    CFG -->|cfg.Database| DB
    CFG -->|cfg.Redis| REDIS
    CFG -->|cfg.App| SERVER
    CFG -->|cfg.App| WORKER

    style CFG fill:#4C9BE8,color:#fff
    style ENV fill:#5CB85C,color:#fff

DRY at the Architecture Level — Shared Domain Models #

In systems with multiple services or handlers, DRY at the architecture level means putting shared logic in a shared layer — not duplicating it in every service.

internal/
  ├── domain/
  │   ├── pricing/
  │   │   ├── tax.go          ← VAT calculation — used by all services
  │   │   └── discount.go     ← discount rules — one source of truth
  │   └── user/
  │       ├── age.go          ← age validation — used by all services
  │       └── status.go       ← user status rules
  ├── validator/
  │   ├── email.go            ← email format validation
  │   └── phone.go            ← phone number validation
  ├── order/
  │   └── service.go          ← uses domain/pricing, not its own implementation
  └── invoice/
      └── service.go          ← uses the same domain/pricing

This structure ensures every business rule exists in exactly one place. When VAT changes from 11% to 12%, you change one line in domain/pricing/tax.go — and all services follow automatically.

flowchart TD
    DOMAIN["domain/pricing\n(shared knowledge)"]
    ORDER["order/service\n(uses domain)"]
    INVOICE["invoice/service\n(uses domain)"]
    REPORT["report/service\n(uses domain)"]
    TAX["VATRate = 0.11"]

    DOMAIN -->|exports| TAX
    ORDER -->|imports| DOMAIN
    INVOICE -->|imports| DOMAIN
    REPORT -->|imports| DOMAIN

    style DOMAIN fill:#4C9BE8,color:#fff
    style TAX fill:#5CB85C,color:#fff

DRY vs Over-Engineering — The Boundary Most Often Crossed #

The DRY paradox: trying too aggressively to remove duplication can produce the wrong abstraction — and a wrong abstraction is far more expensive than simple duplication.

// ANTI-PATTERN: premature abstraction because two functions "look similar"

// These two functions happen to look similar now...
func validateOrderAmount(amount float64) error {
    if amount <= 0 {
        return errors.New("amount must be positive")
    }
    return nil
}

func validateShippingCost(cost float64) error {
    if cost <= 0 {
        return errors.New("cost must be positive")
    }
    return nil
}

// Then someone "DRY-ifies" them:
func validatePositiveFloat(value float64, fieldName string) error {
    if value <= 0 {
        return fmt.Errorf("%s must be positive", fieldName)
    }
    return nil
}

// A year later: the rules change
// - Order amount: negative allowed for returns/refunds
// - Shipping cost: minimum 5000, not just > 0
// Now that abstraction blocks changes that should have been independent
// CORRECT: abstract only when the knowledge is truly the same
// and will change together

// The rule "item price can't be negative" applies to all catalog items
// → this is the same knowledge, worth abstracting
func validateCatalogItemPrice(price float64) error {
    if price < 0 {
        return errors.New("catalog item price cannot be negative")
    }
    return nil
}

// Order amount and shipping cost rules have different lifecycles
// → keep them separate even though they look similar now
func validateOrderAmount(amount float64) error {
    if amount <= 0 {
        return errors.New("order amount must be positive")
    }
    return nil
}

func validateShippingCost(cost float64) error {
    if cost < 5000 {
        return errors.New("minimum shipping cost is Rp5.000")
    }
    return nil
}

A useful rule of thumb for deciding when to abstract:

ABSTRACT SAFELY when:
  ✓ The same logic appears three times or more (the Rule of Three)
  ✓ If one changes, the others MUST change too
  ✓ You can give the abstraction a clear name
  ✓ The abstraction doesn't need many optional parameters or flags

HOLD BACK when:
  ✗ Only two occurrences — might be coincidental similarity
  ✗ Not sure whether these are the same rule or different rules
  ✗ The abstraction needs many parameters to handle various cases
  ✗ The abstraction makes the code harder to read and follow
“Duplication is far cheaper than the wrong abstraction.” — Sandi Metz. A wrong abstraction is too expensive to undo because it’s already spread everywhere and all callers depend on it. Duplication can be removed at any time. A wrong abstraction requires a massive refactor.

DRY’s Relationship with Other Principles #

DRY doesn’t stand alone. It’s closely related to several other principles that reinforce each other:

PrincipleRelationship with DRY
SSOT (Single Source of Truth)DRY is the application of SSOT at the code level. Both state that every fact/knowledge may only exist in one place.
SRP (Single Responsibility)SRP encourages separating responsibilities, which naturally reduces duplication — each responsibility exists in only one component.
YAGNI (You Aren’t Gonna Need It)YAGNI prevents over-abstraction. Together with DRY, they form a balance: abstract when there’s real duplication, but don’t abstract something that “might repeat later”.
Clean ArchitectureDRY at the architecture level means shared logic lives in the domain layer, not duplicated in every layer or service.
OCP (Open/Closed)The abstractions DRY produces often take the form of interfaces or functions that can be extended without being modified.
flowchart LR
    DRY["DRY\n(Don't Repeat Yourself)"]
    SSOT["SSOT\nSingle source of truth"]
    SRP["SRP\nSingle responsibility"]
    YAGNI["YAGNI\nDon't abstract\nbefore its time"]
    CA["Clean Architecture\nShared domain model"]

    DRY <-->|equivalent at code level| SSOT
    DRY -->|reinforced by| SRP
    DRY <-->|balanced by| YAGNI
    DRY -->|leads to| CA

    style DRY fill:#4C9BE8,color:#fff

Anti-Patterns at a Glance #

// ✗ Business logic duplication — the same rule in two places
func registerUser(age int) error  { if age < 18 { return err } ... }
func updateProfile(age int) error { if age < 18 { return err } ... }

// ✗ Magic numbers — what does this 3 mean? order retry or payment?
if retryCount > 3 { markFailed() }
if attempts > 3 { refund() }

// ✗ Cross-layer validation — handler, service, and repo all validate email
func handler()     { if email == "" { return error } }
func service()     { if email == "" { return error } }
func repository()  { if email == "" { return error } }

// ✗ Hardcoded config scattered around
func connectDB()    { sql.Open("postgres", "localhost:5432") }
func connectRedis() { redis.NewClient(&Options{Addr: "localhost:6379"}) }

// ✗ Domain knowledge outside the relevant struct
subtotal := order.Items[0].Price + order.Items[1].Price // Order knowledge in a handler
if order.Status != "pending" && order.Status != "draft" { ... } // also in a handler

DRY Review Checklist #

BUSINESS LOGIC:
  □ Every business rule exists in only one function/method/package
  □ Error messages for the same rule are consistent everywhere
  □ Changing a business rule only requires a change in one place

CONSTANTS AND CONFIGURATION:
  □ No scattered magic numbers — everything has a named constant
  □ Configuration read from one source and passed to components
  □ No identical string literal appearing in more than one file

VALIDATION:
  □ Input format validation lives in one validator package
  □ Business rule validation lives in service/domain, not in handlers and repositories
  □ No validation logic with different implementations across two layers

STRUCTS AND DOMAIN:
  □ Knowledge about a domain lives on that domain struct
  □ Calculations (tax, discount, total) live in struct methods, not in services
  □ Business state checks (IsProcessable, IsCancellable) live in struct methods

ARCHITECTURE:
  □ Shared logic lives in a shared domain package, not duplicated in every service
  □ No two services implementing identical calculations independently
  □ Changing one place doesn't require grepping for other copies

Summary #

  • DRY isn’t a ban on code copy-paste — what’s prohibited is duplicating knowledge: the same business rule, logic, or design decision represented in more than one place.
  • The right test: “If this rule changes in one place, must the other definitely change too?” — if yes, it’s knowledge duplication that should be resolved.
  • Common violation patterns: business logic duplication, scattered magic numbers, validation re-run in every layer, hardcoded config, and domain knowledge living outside its struct.
  • Function-level application: extract repeated logic into named functions reflecting the domain concept — naming is part of DRY’s value.
  • Struct-level application: attach knowledge to the most relevant struct via methods (order.Total(), order.IsProcessable()) so services speak the domain language.
  • Architecture-level application: put shared logic in a shared domain package — all services import from there instead of implementing their own.
  • The danger of premature abstraction: two pieces of code that look the same don’t necessarily represent the same knowledge. Use the Rule of Three: abstraction is safe after three occurrences, not two.
  • Relationship with YAGNI: DRY and YAGNI work together — abstract when there’s real duplication, but don’t abstract something that “might repeat later”.
  • Key quote: “Duplication is far cheaper than the wrong abstraction.” A wrong abstraction costs more than duplication because it’s harder to undo.

← Previous: SOLID   Next: KISS →

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