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:#fffCommon 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:#fffDRY 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:#fffDRY 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:
| Principle | Relationship 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 Architecture | DRY 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:#fffAnti-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.