Part 01 · Low Level Design

Creational Patterns: Factory, Builder, Singleton

FactoryAbstract FactoryBuilderSingleton

Hide which class gets built, tame constructors with ten options, and why Singleton is the pattern you reach for last.

⏱ 2-3 hours · Station 7 of 80

new in forty places

A courier company's app sends OTPs. Forty places in the code do sender = new SmsSender(apiKey, "BD", 3, true). Then the company adds email OTPs for corporate clients, and a different SMS provider for Grameenphone numbers. Forty places now need an if, and each one gets the four constructor arguments slightly differently.

The same app books parcels with a constructor that takes eleven arguments: new Parcel("P-1", "Dhaka", "Sylhet", 2, 1500, true, false, null, 0, "", false). Nobody remembers which true means fragile. A parcel with 45 kg gets created, sent to a van, and rejected at the hub.

Both bugs come from the same place: how objects get made. Creational patterns are a few standard answers to that question.

The problem: creation spreads everywhere

SymptomPattern that helps
if channel == "sms" { new X } else { new Y } repeated across the codeFactory
A constructor with many optional arguments, called with null, 0, falseBuilder
Objects that can exist in an invalid, half-filled stateBuilder (validate once in Build)
"We need exactly one config / one connection pool"Usually dependency injection, sometimes Singleton
Families of objects that must match (Bangla UI widgets with Bangla fonts)Abstract Factory

Think of ordering at a tea stall and a tailor

At a tea stall you say "one milk tea". You don't tell the stall owner which kettle, which cup or which supplier's milk. The stall knows how to make each item on the board. That's a factory: you name what you want; it decides which concrete thing to make.

At a tailor, a shirt has a few required things (size, cloth) and many optional ones (pocket, collar type, cuffs, monogram). The tailor writes your choices on a slip one by one, then checks it all makes sense ("no French cuffs on a half-sleeve") before cutting cloth. That's a builder: collect settings step by step, validate once, then produce the finished object.

factorybuildersendOTP()asks for "sms"NewSender()knows the typesSMSEmail"sms"NewParcel(id, from, to).Weight(2).CashOnDelivery(1500).Fragile()Build()checks every ruleParcel P-1finished, validbuilderonly a valid parcel leaves Build()
Press play. Left: the caller only says "sms", and NewSender picks the SMS type. Right: optional settings are added step by step, and Build checks the whole parcel once before a finished, valid Parcel comes out.

Where the comparison breaks down. A tailor can phone you about a problem. A builder can only refuse with an error, so its error messages must be clear enough to fix the call without reading its code.

Predict first

NewSender(channel) is the only place that turns 'sms' or 'email' into an object. The company adds WhatsApp OTPs. How many call sites that send OTPs need to change?

Four ways to create objects well

  1. Factory (factory method or simple factory): one function or method decides which concrete type to create, from some input. Callers depend on the interface and the factory, never on concrete classes. The switch on type still exists, but once, in the factory.
  2. Abstract Factory: a factory for a family of related objects that must match: BanglaTheme makes Bangla buttons, Bangla date pickers and a Bangla font; EnglishTheme makes the English set. You pick the family once and can't accidentally mix them.
  3. Builder: separates collecting settings from making the object. Required fields go in the constructor, optional ones as named steps, and Build() validates the whole thing once. The result: readable calls (.Fragile() instead of a bare true) and no half-valid objects. In Python, keyword arguments with defaults often do the same job; in Rust, builders usually take self by value.
  4. Singleton: guarantees one instance of a class, usually with a global access point. It solves a real need (one connection pool, one config) but causes real pain: hidden dependencies, shared state between tests, hard-to-replace collaborators. Reach for it last. Creating one instance in main and passing it in gives the "only one" property without the global.
  5. The goal of all of them is the same: keep the decision "which concrete thing, with which settings, is it valid?" in one place, so the rest of the code just uses objects.

Building a parcel, step by step

Step through it

NewParcel("P-3", Dhaka, Rajshahi).Weight(45).Build()

CallerParcelBuilderBuild checksParcel

Step 1/4 Required fields first

The constructor takes what every parcel must have: ID, from, to. Weight defaults to 1 kg.

id
P-3
from
Dhaka
to
Rajshahi
kg
1

Who decides what gets made?

Draw a line between using objects and making them:

  • Most of your code should only use objects through interfaces.
  • A few small places make them: factories, builders, and main, which wires everything together at start-up.

If you see new ConcreteThing(...) deep inside business logic, ask: should this be a factory call, or a dependency passed in?

Try it: which creational pattern fits?

Try each case

Pick the pattern

gateway := ??? ("BD")
works: Factory. Input (country) decides the concrete type; callers use the Gateway interface.

Senders and parcels in code

A factory that turns a channel name into a Sender, and a builder for parcels that validates the whole parcel in Build().

package main

import (
	"errors"
	"fmt"
	"strings"
)

// ---- Factory: callers ask for "a sender for this channel" and never name the concrete type.

type Sender interface {
	Send(to, msg string) string
}

type SMS struct{}

func (SMS) Send(to, msg string) string { return "sms    -> " + to + ": " + msg }

type Email struct{}

func (Email) Send(to, msg string) string { return "email  -> " + to + ": " + msg }

// NewSender is the one place that knows which type serves which channel.
func NewSender(channel string) (Sender, error) {
	switch channel {
	case "sms":
		return SMS{}, nil
	case "email":
		return Email{}, nil
	}
	return nil, fmt.Errorf("unknown channel %q", channel)
}

// ---- Builder: a parcel has a few required fields and several optional ones.

type Parcel struct {
	ID, From, To string
	Kg, COD      int
	Fragile      bool
}

type ParcelBuilder struct{ p Parcel }

func NewParcel(id, from, to string) *ParcelBuilder {
	return &ParcelBuilder{Parcel{ID: id, From: from, To: to, Kg: 1}}
}
func (b *ParcelBuilder) Weight(kg int) *ParcelBuilder         { b.p.Kg = kg; return b }
func (b *ParcelBuilder) CashOnDelivery(tk int) *ParcelBuilder { b.p.COD = tk; return b }
func (b *ParcelBuilder) Fragile() *ParcelBuilder              { b.p.Fragile = true; return b }

// Build checks the whole object once, so a half-valid parcel can never exist.
func (b *ParcelBuilder) Build() (Parcel, error) {
	if b.p.Kg < 1 || b.p.Kg > 30 {
		return Parcel{}, errors.New("weight must be 1-30 kg")
	}
	if b.p.From == b.p.To {
		return Parcel{}, errors.New("from and to are the same city")
	}
	return b.p, nil
}

func (p Parcel) String() string {
	parts := []string{fmt.Sprintf("%s -> %s, %d kg", p.From, p.To, p.Kg)}
	if p.COD > 0 {
		parts = append(parts, fmt.Sprintf("cod %d tk", p.COD))
	}
	if p.Fragile {
		parts = append(parts, "fragile")
	}
	return strings.Join(parts, ", ")
}

func main() {
	for _, ch := range []string{"sms", "email", "push"} {
		s, err := NewSender(ch)
		if err != nil {
			fmt.Printf("%-6s : %v\n", ch, err)
			continue
		}
		to := map[string]string{"sms": "01711000000", "email": "rahim@example.com"}[ch]
		fmt.Println(s.Send(to, "Your OTP is 4821"))
	}

	builders := []*ParcelBuilder{
		NewParcel("P-1", "Dhaka", "Sylhet").Weight(2).CashOnDelivery(1500).Fragile(),
		NewParcel("P-2", "Dhaka", "Khulna"),
		NewParcel("P-3", "Dhaka", "Rajshahi").Weight(45),
		NewParcel("P-4", "Dhaka", "Dhaka").Weight(3),
	}
	for _, b := range builders {
		p, err := b.Build()
		if err != nil {
			fmt.Printf("parcel %s: refused: %v\n", b.p.ID, err)
			continue
		}
		fmt.Printf("parcel %s: %s\n", p.ID, p)
	}
}

All six print exactly this:

sms    -> 01711000000: Your OTP is 4821
email  -> rahim@example.com: Your OTP is 4821
push   : unknown channel "push"
parcel P-1: Dhaka -> Sylhet, 2 kg, cod 1500 tk, fragile
parcel P-2: Dhaka -> Khulna, 1 kg
parcel P-3: refused: weight must be 1-30 kg
parcel P-4: refused: from and to are the same city

Reading the output:

  • sms / email: the loop never names a concrete type. It passes a string and gets something that can Send.
  • push: the factory refuses unknown channels with a clear error, in one place, instead of each caller checking.
  • P-1: every optional setting is named at the call site: .Weight(2).CashOnDelivery(1500).Fragile(). No mystery true.
  • P-2: nothing optional set, so the defaults apply (1 kg, no COD, not fragile).
  • P-3 and P-4: Build refuses a 45 kg parcel and a parcel going nowhere. No invalid Parcel object was ever created.
  • What each language changed: Python's factory is a dict from name to class; many Python teams would replace the builder with keyword arguments plus a check in __post_init__. TypeScript's builder methods return this, so chaining keeps the type. Rust's builder methods take mut self and return Self, moving the builder through each step. Java's Map.of("sms", Sms::new, ...) stores constructor references, and C++ stores lambdas that return unique_ptr<Sender>, so the factory hands ownership to the caller.

Creating objects in real projects

  • Wire everything in main (the composition root). Create the config, the DB pool, the senders and the services once at start-up and pass them in. This replaces most Singletons and makes the dependency graph visible in one function.
  • Make factories data-driven when variants grow. A map from name to constructor (as in the Python and Java versions) lets you add a channel with one line, and even register channels from plugins.
  • Validate in the builder, not in every caller. If a rule ("weight 1-30 kg") must always hold, Build is the gate.
  • Prefer immutability for the built object. Once Build succeeds, the parcel shouldn't be editable; otherwise someone can set Kg = 45 afterwards and skip the check.
  • If you truly need a Singleton (a process-wide registry, for example), keep it behind an interface, initialise it once explicitly, and give tests a way to replace it.

Designs that cause problems later

Try each case

Pick the creation code that will hurt

s := NewSmsSender(key, "BD", 3, true)
breaks: Every new channel or argument edits 40 places. Put creation in one factory.

Pause and think

A teammate says: "The app needs exactly one database connection pool, so let's make DB a Singleton with DB.getInstance()." What would you suggest?

Reveal

Create the pool once in main and pass it to the repositories that need it. You still get exactly one pool in production, but now every class shows in its constructor that it uses the database, tests can pass a fake or a test database, and two pools for two databases (say, a read replica) are trivial. The "one instance" requirement is about how the program is wired, not something the class itself has to enforce.

Quick quiz

1. What problem does a factory solve?

Answer

It keeps the decision "which concrete type for this input?" in one place, so callers depend only on the interface and new variants don't touch them.

2. Why validate in Build() instead of in the setter methods?

Answer

Some rules involve several fields together (from must differ from to), and a builder is allowed to be incomplete while it's being filled. Checking once at the end guarantees that every object that exists is valid.

3. What's the difference between a factory and an abstract factory?

Answer

A factory creates one kind of product (a sender). An abstract factory creates a family of related products that must match (a theme's buttons, icons and fonts).

4. Why is Singleton usually the last choice?

Answer

It's global state: it hides dependencies, ties code to one concrete class, and leaks state between tests. Creating one instance at start-up and passing it in gives the same "only one" without those costs.

Your challenge: WhatsApp OTP and insurance

  • Add a WhatsApp sender that prints whatsapp -> <to>: <msg> and register it in the factory under "whatsapp". Change the channel list to sms, email, whatsapp, push, with 01811000000 as the WhatsApp address.
  • Add an optional insurance step to the builder, .Insured(), shown as insured at the end of the parcel line. Add a rule to Build: an insured parcel must declare COD of at least 500 taka. Make P-1 insured.

Before you run it, predict the new lines.

You've succeeded when the output gains whatsapp -> 01811000000: Your OTP is 4821, P-1 becomes parcel P-1: Dhaka -> Sylhet, 2 kg, cod 1500 tk, fragile, insured, every other line is unchanged, and the sending loop has no new if.

In one line

Keep creation in a few places: a factory decides which concrete type to make, a builder collects optional settings by name and validates the whole object once, and main wires the single instances together. Most code should only use objects; Singleton is the last resort, not the first.

Station stamp

Finish the chapter

Done reading? Press Complete to save your progress. XP is Pro-only — on Pro this chapter is worth +10 XP.

Go Pro to earn XP →