Low Level DesignSOLID principles
Liskov substitution principle
The Liskov substitution principle (LSP) through three real examples, from a read-only tag list to storage backends to a rate limiter's counters, with live contract checks, class diagrams and code in five languages.
- Read first
- Open/closed
The Liskov substitution principle says that anything written against a type must keep working with
every subtype of it. If code is correct for a Tags, it must be correct for every class that claims
to be a Tags. That includes ones written years later, by someone else.
Barbara Liskov stated it in a 1987 keynote, and she and Jeannette Wing made it precise in 1994.
The key word is behavior. The compiler already checks that a subtype has the right methods
with the right signatures. LSP is about what the compiler can’t check: the promises those
methods make. A subclass that has an add() method but throws when you call it fits the type
perfectly and breaks every caller.
This is the principle that makes open/closed safe. Plugging new classes into old code only works if the new classes keep the old promises. This page shows three real ways they don’t. Pick a level below. The rest of the page is shared.
The rules
Section titled “The rules”Every LSP violation breaks one of five rules. For a subtype to be a safe substitute:
| Rule | In plain words | Broken when |
|---|---|---|
| Preconditions can’t be strengthened | It accepts at least everything the parent accepts | The parent takes any size; the subtype refuses over 1 KB |
| Postconditions can’t be weakened | It guarantees at least everything the parent guarantees | The parent never forgets a count; the subtype evicts |
| Invariants must hold | What’s always true of the parent stays true | A “read-only” view whose contents change under you |
| No new kinds of failure | It throws only what the parent’s callers already handle | add() throws “not supported” |
| History can’t change in new ways | It doesn’t allow changes the parent rules out | An immutable Point with a mutable subclass |
The test for all five is the same: take a test written for the parent, and run it against the subtype. If it fails, the subtype isn’t one, whatever the compiler says. That’s why the examples below all end with one contract test suite, run against every implementation.
Worked examples
Section titled “Worked examples”A read-only tag list
- Promises the compiler cannot see
- Splitting the type
- Asking for only what you use
A blogging platform lets authors tag posts. Every new post gets a “new” tag added by default.
There are also site-wide tags, loaded from config, that must never change while the site runs.
Someone made a FrozenTags class for those that refuses add().
This exact design is in the standard libraries. In Java, List.of("a").add("b") throws
UnsupportedOperationException. In .NET, an array is an IList<T> and Add on it throws
NotSupportedException. Java documents add as an “optional operation”, and .NET gives callers
an IsReadOnly flag to check first. Both are polite ways of saying the type promises nothing.
Without the principle
Section titled “Without the principle”One Tags interface covers every tag list, editable or not:
import java.util.ArrayList;import java.util.List;
// One interface for every kind of tag list, editable or not.interface Tags { boolean contains(String tag); List<String> all(); void add(String tag);}
class ListTags implements Tags { private final List<String> tags = new ArrayList<>(); public boolean contains(String tag) { return tags.contains(tag); } public List<String> all() { return List.copyOf(tags); } public void add(String tag) { tags.add(tag); }}
// Site-wide tags from config must never change while the site runs, so add() refuses.// The JDK does the same: List.of("a").add("b") throws UnsupportedOperationException.// flaw: it claims to be Tags, but it breaks the one promise add() makes. The compiler// can't tell; the crash happens in production, in code that has never heard of FrozenTags.class FrozenTags implements Tags { private final List<String> tags; FrozenTags(List<String> tags) { this.tags = List.copyOf(tags); } public boolean contains(String tag) { return tags.contains(tag); } public List<String> all() { return tags; } public void add(String tag) { throw new UnsupportedOperationException("tags are frozen"); }}
class Posts { // Written against Tags, and correct for every Tags that keeps its promises. static void addDefaultTags(Tags tags) { if (!tags.contains("new")) tags.add("new"); }}
class Main { public static void main(String[] args) { Tags draft = new ListTags(); Posts.addDefaultTags(draft); System.out.println(draft.all()); // [new]
Tags siteWide = new FrozenTags(List.of("featured")); Posts.addDefaultTags(siteWide); // compiles fine, throws at run time }}from typing import Protocol
# One protocol for every kind of tag list, editable or not.class Tags(Protocol): def contains(self, tag: str) -> bool: ... def all(self) -> list[str]: ... def add(self, tag: str) -> None: ...
class ListTags: def __init__(self) -> None: self._tags: list[str] = []
def contains(self, tag: str) -> bool: return tag in self._tags
def all(self) -> list[str]: return list(self._tags)
def add(self, tag: str) -> None: self._tags.append(tag)
# Site-wide tags from config must never change while the site runs, so add() refuses.# flaw: it claims to be Tags, but it breaks the one promise add() makes. The type checker# is satisfied; the crash happens in production, in code that has never heard of FrozenTags.class FrozenTags: def __init__(self, tags: list[str]) -> None: self._tags = tuple(tags)
def contains(self, tag: str) -> bool: return tag in self._tags
def all(self) -> list[str]: return list(self._tags)
def add(self, tag: str) -> None: raise TypeError("tags are frozen")
# Written against Tags, and correct for every Tags that keeps its promises.def add_default_tags(tags: Tags) -> None: if not tags.contains("new"): tags.add("new")
if __name__ == "__main__": draft = ListTags() add_default_tags(draft) print(draft.all()) # ['new']
site_wide = FrozenTags(["featured"]) add_default_tags(site_wide) # type checks fine, raises at run timeusing System;using System.Collections.Generic;
// One interface for every kind of tag list, editable or not.interface ITags{ bool Contains(string tag); IReadOnlyList<string> All(); void Add(string tag);}
class ListTags : ITags{ private readonly List<string> _tags = new(); public bool Contains(string tag) => _tags.Contains(tag); public IReadOnlyList<string> All() => _tags.ToArray(); public void Add(string tag) => _tags.Add(tag);}
// Site-wide tags from config must never change while the site runs, so Add() refuses.// .NET does the same: an array is an IList<T>, and calling Add on it throws NotSupportedException.// flaw: it claims to be ITags, but it breaks the one promise Add() makes. The compiler// can't tell; the crash happens in production, in code that has never heard of FrozenTags.class FrozenTags : ITags{ private readonly string[] _tags; public FrozenTags(IEnumerable<string> tags) => _tags = new List<string>(tags).ToArray(); public bool Contains(string tag) => Array.IndexOf(_tags, tag) >= 0; public IReadOnlyList<string> All() => _tags; public void Add(string tag) => throw new NotSupportedException("tags are frozen");}
static class Posts{ // Written against ITags, and correct for every ITags that keeps its promises. public static void AddDefaultTags(ITags tags) { if (!tags.Contains("new")) tags.Add("new"); }}
static class Program{ static void Main() { ITags draft = new ListTags(); Posts.AddDefaultTags(draft); Console.WriteLine(string.Join(",", draft.All())); // new
ITags siteWide = new FrozenTags(new[] { "featured" }); Posts.AddDefaultTags(siteWide); // compiles fine, throws at run time }}package main
import ( "errors" "fmt" "slices")
// Tags is one interface for every kind of tag list, editable or not.type Tags interface { Contains(tag string) bool All() []string Add(tag string) error}
type ListTags struct{ tags []string }
func (s *ListTags) Contains(tag string) bool { return slices.Contains(s.tags, tag) }func (s *ListTags) All() []string { return slices.Clone(s.tags) }func (s *ListTags) Add(tag string) error { s.tags = append(s.tags, tag); return nil }
// FrozenTags holds site-wide tags from config, which must never change while the site runs,// so Add refuses. flaw: it claims to be Tags, but it breaks the promise Add makes. Every// caller now has to handle an error that, for every other Tags, can never happen.type FrozenTags struct{ tags []string }
func (f FrozenTags) Contains(tag string) bool { return slices.Contains(f.tags, tag) }func (f FrozenTags) All() []string { return slices.Clone(f.tags) }func (f FrozenTags) Add(string) error { return errors.New("tags are frozen") }
// AddDefaultTags was written against Tags, and is correct for every Tags that keeps its// promises. Its author never expected Add to fail, so the error is dropped.func AddDefaultTags(tags Tags) { if !tags.Contains("new") { _ = tags.Add("new") }}
func main() { draft := &ListTags{} AddDefaultTags(draft) fmt.Println(draft.All()) // [new]
siteWide := FrozenTags{[]string{"featured"}} AddDefaultTags(siteWide) fmt.Println(siteWide.All()) // [featured]: the default tag silently never arrived}// One interface for every kind of tag list, editable or not.interface Tags { contains(tag: string): boolean; all(): readonly string[]; add(tag: string): void;}
class ListTags implements Tags { private readonly tags: string[] = []; contains(tag: string) { return this.tags.includes(tag); } all() { return [...this.tags]; } add(tag: string) { this.tags.push(tag); }}
// Site-wide tags from config must never change while the site runs, so add() refuses.// JavaScript does the same: push() on a frozen array throws a TypeError.// flaw: it claims to be Tags, but it breaks the one promise add() makes. The compiler// can't tell; the crash happens in production, in code that has never heard of FrozenTags.class FrozenTags implements Tags { private readonly tags: readonly string[]; constructor(tags: readonly string[]) { this.tags = Object.freeze([...tags]); } contains(tag: string) { return this.tags.includes(tag); } all() { return this.tags; } add(_tag: string): void { throw new Error("tags are frozen"); }}
// Written against Tags, and correct for every Tags that keeps its promises.function addDefaultTags(tags: Tags): void { if (!tags.contains("new")) tags.add("new");}
const draft = new ListTags();addDefaultTags(draft);console.log(draft.all()); // [ 'new' ]
const siteWide = new FrozenTags(["featured"]);addDefaultTags(siteWide); // compiles fine, throws at run timeaddDefaultTags is correct. It was written against Tags, and uses it as documented. It crashes
because one class claims to be a Tags, and isn’t. The bug isn’t in the code that crashed. It’s in
a class the crashing code has never heard of. That’s what makes LSP violations expensive: the stack
trace points at the wrong place.
Try each class in the caller. All three compile:
addDefaultTags(tags)- add() stores the tag
- contains() finds a tag right after add()
- add() never throws
The caller relies on each of these promises. Pick a class to check them.
With LSP
Section titled “With LSP”Split the type by the promises each class can keep. Reading is something every tag list can do. Editing is an extra promise only some can. Then each function asks for exactly what it uses.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
import java.util.ArrayList;import java.util.List;
// Split by what callers need. Reading is a promise every tag list can keep...interface ReadableTags { boolean contains(String tag); List<String> all();}
// ...and editing is an extra promise only some can. Every EditableTags is a ReadableTags,// and that is true: it can do everything a readable list can, plus more.interface EditableTags extends ReadableTags { void add(String tag);}
class ListTags implements EditableTags { private final List<String> tags = new ArrayList<>(); public boolean contains(String tag) { return tags.contains(tag); } public List<String> all() { return List.copyOf(tags); } public void add(String tag) { tags.add(tag); }}
// No add() to break. It only claims what it can keep.class FrozenTags implements ReadableTags { private final List<String> tags; FrozenTags(List<String> tags) { this.tags = List.copyOf(tags); } public boolean contains(String tag) { return tags.contains(tag); } public List<String> all() { return tags; }}
class Posts { // Asks for exactly what it uses. Passing FrozenTags is now a compile error, // found by the person writing the call, not by a user. static void addDefaultTags(EditableTags tags) { if (!tags.contains("new")) tags.add("new"); }
// Only reads, so it accepts both kinds. static boolean isFeatured(ReadableTags tags) { return tags.contains("featured"); }}
class Main { public static void main(String[] args) { EditableTags draft = new ListTags(); Posts.addDefaultTags(draft); System.out.println(draft.all()); // [new]
ReadableTags siteWide = new FrozenTags(List.of("featured")); System.out.println(Posts.isFeatured(siteWide)); // true // Posts.addDefaultTags(siteWide); // does not compile: FrozenTags can't promise add() }}from typing import Protocol
# Split by what callers need. Reading is a promise every tag list can keep...class ReadableTags(Protocol): def contains(self, tag: str) -> bool: ... def all(self) -> list[str]: ...
# ...and editing is an extra promise only some can. Anything editable is also readable.class EditableTags(ReadableTags, Protocol): def add(self, tag: str) -> None: ...
class ListTags: def __init__(self) -> None: self._tags: list[str] = []
def contains(self, tag: str) -> bool: return tag in self._tags
def all(self) -> list[str]: return list(self._tags)
def add(self, tag: str) -> None: self._tags.append(tag)
# No add() to break. It only claims what it can keep.class FrozenTags: def __init__(self, tags: list[str]) -> None: self._tags = tuple(tags)
def contains(self, tag: str) -> bool: return tag in self._tags
def all(self) -> list[str]: return list(self._tags)
# Asks for exactly what it uses. Passing FrozenTags is now an error a type checker (mypy,# pyright) reports before the code runs, not a crash a user finds.def add_default_tags(tags: EditableTags) -> None: if not tags.contains("new"): tags.add("new")
# Only reads, so it accepts both kinds.def is_featured(tags: ReadableTags) -> bool: return tags.contains("featured")
if __name__ == "__main__": draft = ListTags() add_default_tags(draft) print(draft.all()) # ['new']
site_wide = FrozenTags(["featured"]) print(is_featured(site_wide)) # True # add_default_tags(site_wide) # type checker: FrozenTags has no add()using System;using System.Collections.Generic;
// Split by what callers need. Reading is a promise every tag list can keep...interface IReadableTags{ bool Contains(string tag); IReadOnlyList<string> All();}
// ...and editing is an extra promise only some can. Every IEditableTags is an// IReadableTags, and that is true: it can do everything a readable list can, plus more.interface IEditableTags : IReadableTags{ void Add(string tag);}
class ListTags : IEditableTags{ private readonly List<string> _tags = new(); public bool Contains(string tag) => _tags.Contains(tag); public IReadOnlyList<string> All() => _tags.ToArray(); public void Add(string tag) => _tags.Add(tag);}
// No Add() to break. It only claims what it can keep.class FrozenTags : IReadableTags{ private readonly string[] _tags; public FrozenTags(IEnumerable<string> tags) => _tags = new List<string>(tags).ToArray(); public bool Contains(string tag) => Array.IndexOf(_tags, tag) >= 0; public IReadOnlyList<string> All() => _tags;}
static class Posts{ // Asks for exactly what it uses. Passing FrozenTags is now a compile error, // found by the person writing the call, not by a user. public static void AddDefaultTags(IEditableTags tags) { if (!tags.Contains("new")) tags.Add("new"); }
// Only reads, so it accepts both kinds. public static bool IsFeatured(IReadableTags tags) => tags.Contains("featured");}
static class Program{ static void Main() { IEditableTags draft = new ListTags(); Posts.AddDefaultTags(draft); Console.WriteLine(string.Join(",", draft.All())); // new
IReadableTags siteWide = new FrozenTags(new[] { "featured" }); Console.WriteLine(Posts.IsFeatured(siteWide)); // True // Posts.AddDefaultTags(siteWide); // does not compile: FrozenTags can't promise Add() }}package main
import ( "fmt" "slices")
// ReadableTags is the promise every tag list can keep...type ReadableTags interface { Contains(tag string) bool All() []string}
// EditableTags is an extra promise only some can. It embeds ReadableTags, because anything// editable is also readable.type EditableTags interface { ReadableTags Add(tag string)}
type ListTags struct{ tags []string }
func (s *ListTags) Contains(tag string) bool { return slices.Contains(s.tags, tag) }func (s *ListTags) All() []string { return slices.Clone(s.tags) }func (s *ListTags) Add(tag string) { s.tags = append(s.tags, tag) }
// FrozenTags has no Add to break. It only claims what it can keep.type FrozenTags struct{ tags []string }
func (f FrozenTags) Contains(tag string) bool { return slices.Contains(f.tags, tag) }func (f FrozenTags) All() []string { return slices.Clone(f.tags) }
// AddDefaultTags asks for exactly what it uses. Passing FrozenTags is now a compile error.func AddDefaultTags(tags EditableTags) { if !tags.Contains("new") { tags.Add("new") }}
// IsFeatured only reads, so it accepts both kinds.func IsFeatured(tags ReadableTags) bool { return tags.Contains("featured") }
func main() { draft := &ListTags{} AddDefaultTags(draft) fmt.Println(draft.All()) // [new]
siteWide := FrozenTags{[]string{"featured"}} fmt.Println(IsFeatured(siteWide)) // true // AddDefaultTags(siteWide) // does not compile: FrozenTags does not implement Add}// Split by what callers need. Reading is a promise every tag list can keep...interface ReadableTags { contains(tag: string): boolean; all(): readonly string[];}
// ...and editing is an extra promise only some can. Every EditableTags is a ReadableTags,// and that is true: it can do everything a readable list can, plus more.interface EditableTags extends ReadableTags { add(tag: string): void;}
class ListTags implements EditableTags { private readonly tags: string[] = []; contains(tag: string) { return this.tags.includes(tag); } all() { return [...this.tags]; } add(tag: string) { this.tags.push(tag); }}
// No add() to break. It only claims what it can keep.class FrozenTags implements ReadableTags { private readonly tags: readonly string[]; constructor(tags: readonly string[]) { this.tags = Object.freeze([...tags]); } contains(tag: string) { return this.tags.includes(tag); } all() { return this.tags; }}
// Asks for exactly what it uses. Passing FrozenTags is now a compile error,// found by the person writing the call, not by a user.function addDefaultTags(tags: EditableTags): void { if (!tags.contains("new")) tags.add("new");}
// Only reads, so it accepts both kinds.function isFeatured(tags: ReadableTags): boolean { return tags.contains("featured");}
const draft = new ListTags();addDefaultTags(draft);console.log(draft.all()); // [ 'new' ]
const siteWide = new FrozenTags(["featured"]);console.log(isFeatured(siteWide)); // true// addDefaultTags(siteWide); // does not compile: FrozenTags has no add()What to notice
Section titled “What to notice”- The bug moved from run time to compile time. Passing
FrozenTagstoaddDefaultTagsno longer compiles. The person writing the call finds out, not a user. - The subtype relation is now true. Every
EditableTagsreally can do everything aReadableTagscan, plus more. That’s the direction subtyping should go: subtypes add promises, they don’t remove them. - Callers ask for the least they need.
isFeaturedtakesReadableTags, so it works with both. That’s interface segregation doing LSP’s job. LowercaseTagsis the dangerous kind. It never throws. It just quietly changes whataddmeans. You only catch it by writing down the promise (“containsfinds a tag right afteradd”) and testing it.
We have
Birdwith afly()method. How do you model a penguin?
Whether you refuse to make Penguin.fly() throw, and instead question the base type: what do
callers of Bird actually need? Split the capability out (FlyingBird, or a Flyer interface)
so only birds that fly claim to. Five minutes. The red flag is “throw
UnsupportedOperationException”, or “callers check instanceof Penguin”.
FrozenTags can’t keep add()’s promise, so it shouldn’t claim to have it. I’ll split reading from editing, and have each function ask for only the part it uses.
Swapping storage backends
- Writing the contract down
- One contract test suite
- Adapting a class until it fits
A product stores user uploads behind a BlobStore interface: put bytes under a key, get them
back. It runs on local disk in tests and on cloud object storage in production. To save money,
someone wires in a cheap key-value service instead. It has a put and a get, so it fits.
Storage is where LSP bites hardest, because the important promises are about behavior. How big can a value be? What does “missing” look like? Does a read right after a write see the new data? Amazon S3 didn’t promise that last one for overwrites until December 2020. Code tested on local disk, where it’s always true, could break on S3 without changing a line.
Without the principle
Section titled “Without the principle”The interface had a contract, but only in people’s heads. The new store breaks two of its promises:
import java.util.ArrayList;import java.util.Arrays;import java.util.HashMap;import java.util.List;import java.util.Map;import java.util.NoSuchElementException;
// The contract. Every caller relies on all three promises, so every BlobStore must keep them:// 1. get(key) right after put(key, data) returns the same bytes// 2. any size up to 1 MB is accepted// 3. get() of a missing key returns null; it never throwsinterface BlobStore { void put(String key, byte[] data); byte[] get(String key);}
class InMemoryStore implements BlobStore { private final Map<String, byte[]> blobs = new HashMap<>(); public void put(String key, byte[] data) { blobs.put(key, data.clone()); } public byte[] get(String key) { byte[] b = blobs.get(key); return b == null ? null : b.clone(); }}
// A cheap key-value service someone wired in to save money. It fits the interface...class TinyKvStore implements BlobStore { private final Map<String, byte[]> values = new HashMap<>();
public void put(String key, byte[] data) { // flaw: a stronger precondition than the contract. Callers were promised 1 MB; // this refuses anything over 1 KB. if (data.length > 1024) throw new IllegalArgumentException("value over 1 KB"); values.put(key, data.clone()); }
public byte[] get(String key) { // flaw: a new failure the contract doesn't allow. Every caller checks for null, // none of them catch this. byte[] v = values.get(key); if (v == null) throw new NoSuchElementException(key); return v.clone(); }}
class Contract { // One test suite, run against every implementation. This is how LSP is enforced in practice. static List<String> check(BlobStore store) { List<String> out = new ArrayList<>(); out.add(attempt("round trip", () -> { store.put("a", "hello".getBytes()); return Arrays.equals(store.get("a"), "hello".getBytes()); })); out.add(attempt("5 KB value", () -> { byte[] big = new byte[5_000]; Arrays.fill(big, (byte) 7); store.put("big", big); return Arrays.equals(store.get("big"), big); })); out.add(attempt("missing is null", () -> store.get("nope") == null)); return out; }
private static String attempt(String name, java.util.function.BooleanSupplier test) { try { return (test.getAsBoolean() ? "PASS " : "FAIL ") + name; } catch (RuntimeException e) { return "FAIL " + name + " (" + e.getClass().getSimpleName() + ")"; } }}
class Main { public static void main(String[] args) { System.out.println("InMemoryStore " + Contract.check(new InMemoryStore())); System.out.println("TinyKvStore " + Contract.check(new TinyKvStore())); }}from typing import Callable, Protocol
# The contract. Every caller relies on all three promises, so every BlobStore must keep them:# 1. get(key) right after put(key, data) returns the same bytes# 2. any size up to 1 MB is accepted# 3. get() of a missing key returns None; it never raisesclass BlobStore(Protocol): def put(self, key: str, data: bytes) -> None: ... def get(self, key: str) -> bytes | None: ...
class InMemoryStore: def __init__(self) -> None: self._blobs: dict[str, bytes] = {}
def put(self, key: str, data: bytes) -> None: self._blobs[key] = bytes(data)
def get(self, key: str) -> bytes | None: return self._blobs.get(key)
# A cheap key-value service someone wired in to save money. It fits the protocol...class TinyKvStore: def __init__(self) -> None: self._values: dict[str, bytes] = {}
def put(self, key: str, data: bytes) -> None: # flaw: a stronger precondition than the contract. Callers were promised 1 MB; # this refuses anything over 1 KB. if len(data) > 1024: raise ValueError("value over 1 KB") self._values[key] = bytes(data)
def get(self, key: str) -> bytes | None: # flaw: a new failure the contract doesn't allow. Every caller checks for None, # none of them catch this. return self._values[key] # KeyError when missing
# One test suite, run against every implementation. This is how LSP is enforced in practice.def check_contract(store: BlobStore) -> list[str]: def attempt(name: str, test: Callable[[], bool]) -> str: try: return ("PASS " if test() else "FAIL ") + name except Exception as e: return f"FAIL {name} ({type(e).__name__})"
def round_trip() -> bool: store.put("a", b"hello") return store.get("a") == b"hello"
def big_value() -> bool: big = bytes([7]) * 5_000 store.put("big", big) return store.get("big") == big
return [ attempt("round trip", round_trip), attempt("5 KB value", big_value), attempt("missing is None", lambda: store.get("nope") is None), ]
if __name__ == "__main__": print("InMemoryStore", check_contract(InMemoryStore())) print("TinyKvStore ", check_contract(TinyKvStore()))using System;using System.Collections.Generic;using System.Linq;
// The contract. Every caller relies on all three promises, so every IBlobStore must keep them:// 1. Get(key) right after Put(key, data) returns the same bytes// 2. any size up to 1 MB is accepted// 3. Get() of a missing key returns null; it never throwsinterface IBlobStore{ void Put(string key, byte[] data); byte[]? Get(string key);}
class InMemoryStore : IBlobStore{ private readonly Dictionary<string, byte[]> _blobs = new(); public void Put(string key, byte[] data) => _blobs[key] = (byte[])data.Clone(); public byte[]? Get(string key) => _blobs.TryGetValue(key, out var b) ? (byte[])b.Clone() : null;}
// A cheap key-value service someone wired in to save money. It fits the interface...class TinyKvStore : IBlobStore{ private readonly Dictionary<string, byte[]> _values = new();
public void Put(string key, byte[] data) { // flaw: a stronger precondition than the contract. Callers were promised 1 MB; // this refuses anything over 1 KB. if (data.Length > 1024) throw new ArgumentException("value over 1 KB"); _values[key] = (byte[])data.Clone(); }
// flaw: a new failure the contract doesn't allow. Every caller checks for null, // none of them catch this. The indexer throws KeyNotFoundException when missing. public byte[]? Get(string key) => (byte[])_values[key].Clone();}
static class Contract{ // One test suite, run against every implementation. This is how LSP is enforced in practice. public static List<string> Check(IBlobStore store) { var big = Enumerable.Repeat((byte)7, 5_000).ToArray(); return new List<string> { Attempt("round trip", () => { store.Put("a", "hello"u8.ToArray()); return store.Get("a")!.SequenceEqual("hello"u8.ToArray()); }), Attempt("5 KB value", () => { store.Put("big", big); return store.Get("big")!.SequenceEqual(big); }), Attempt("missing is null", () => store.Get("nope") is null), }; }
private static string Attempt(string name, Func<bool> test) { try { return (test() ? "PASS " : "FAIL ") + name; } catch (Exception e) { return $"FAIL {name} ({e.GetType().Name})"; } }}
static class Program{ static void Main() { Console.WriteLine("InMemoryStore " + string.Join(", ", Contract.Check(new InMemoryStore()))); Console.WriteLine("TinyKvStore " + string.Join(", ", Contract.Check(new TinyKvStore()))); }}package main
import ( "bytes" "errors" "fmt")
// BlobStore is the contract. Every caller relies on all three promises:// 1. Get(key) right after Put(key, data) returns the same bytes// 2. Put accepts any size up to 1 MB without an error// 3. Get of a missing key returns (nil, nil): missing is not an errortype BlobStore interface { Put(key string, data []byte) error Get(key string) ([]byte, error)}
type InMemoryStore struct{ blobs map[string][]byte }
func NewInMemoryStore() *InMemoryStore { return &InMemoryStore{map[string][]byte{}} }
func (s *InMemoryStore) Put(key string, data []byte) error { s.blobs[key] = bytes.Clone(data) return nil}
func (s *InMemoryStore) Get(key string) ([]byte, error) { return bytes.Clone(s.blobs[key]), nil }
// TinyKvStore is a cheap key-value service someone wired in to save money. It fits the interface...type TinyKvStore struct{ values map[string][]byte }
var errNotFound = errors.New("not found")
func (s *TinyKvStore) Put(key string, data []byte) error { // flaw: a stronger precondition than the contract. Callers were promised 1 MB; // this refuses anything over 1 KB. if len(data) > 1024 { return errors.New("value over 1 KB") } s.values[key] = bytes.Clone(data) return nil}
func (s *TinyKvStore) Get(key string) ([]byte, error) { // flaw: an error the contract doesn't allow. Callers treat any error as "the store is // down" and retry or page someone, for what is just a missing key. v, ok := s.values[key] if !ok { return nil, errNotFound } return bytes.Clone(v), nil}
// CheckContract is one test suite, run against every implementation. This is how LSP is// enforced in practice.func CheckContract(store BlobStore) []string { attempt := func(name string, test func() error) string { if err := test(); err != nil { return "FAIL " + name + " (" + err.Error() + ")" } return "PASS " + name } roundTrip := func(key string, data []byte) func() error { return func() error { if err := store.Put(key, data); err != nil { return err } got, err := store.Get(key) if err != nil { return err } if !bytes.Equal(got, data) { return errors.New("different bytes") } return nil } } return []string{ attempt("round trip", roundTrip("a", []byte("hello"))), attempt("5 KB value", roundTrip("big", bytes.Repeat([]byte{7}, 5_000))), attempt("missing is nil", func() error { got, err := store.Get("nope") if err != nil { return err } if got != nil { return errors.New("not nil") } return nil }), }}
func main() { fmt.Println("InMemoryStore", CheckContract(NewInMemoryStore())) fmt.Println("TinyKvStore ", CheckContract(&TinyKvStore{map[string][]byte{}}))}// The contract. Every caller relies on all three promises, so every BlobStore must keep them:// 1. get(key) right after put(key, data) returns the same bytes// 2. any size up to 1 MB is accepted// 3. get() of a missing key returns undefined; it never throwsinterface BlobStore { put(key: string, data: Uint8Array): void; get(key: string): Uint8Array | undefined;}
class InMemoryStore implements BlobStore { private readonly blobs = new Map<string, Uint8Array>(); put(key: string, data: Uint8Array) { this.blobs.set(key, data.slice()); } get(key: string) { return this.blobs.get(key)?.slice(); }}
// A cheap key-value service someone wired in to save money. It fits the interface...class TinyKvStore implements BlobStore { private readonly values = new Map<string, Uint8Array>();
put(key: string, data: Uint8Array) { // flaw: a stronger precondition than the contract. Callers were promised 1 MB; // this refuses anything over 1 KB. if (data.length > 1024) throw new RangeError("value over 1 KB"); this.values.set(key, data.slice()); }
get(key: string) { // flaw: a new failure the contract doesn't allow. Every caller checks for undefined, // none of them catch this. const v = this.values.get(key); if (!v) throw new Error(`not found: ${key}`); return v.slice(); }}
const same = (a: Uint8Array | undefined, b: Uint8Array) => !!a && a.length === b.length && a.every((x, i) => x === b[i]);
// One test suite, run against every implementation. This is how LSP is enforced in practice.function checkContract(store: BlobStore): string[] { const attempt = (name: string, test: () => boolean) => { try { return (test() ? "PASS " : "FAIL ") + name; } catch (e) { return `FAIL ${name} (${(e as Error).name})`; } }; const hello = new TextEncoder().encode("hello"); const big = new Uint8Array(5_000).fill(7); return [ attempt("round trip", () => (store.put("a", hello), same(store.get("a"), hello))), attempt("5 KB value", () => (store.put("big", big), same(store.get("big"), big))), attempt("missing is undefined", () => store.get("nope") === undefined), ];}
console.log("InMemoryStore", checkContract(new InMemoryStore()));console.log("TinyKvStore ", checkContract(new TinyKvStore()));Plug each store into the upload service and check the promises:
the upload service- get() right after put() returns the same bytes
- accepts any size up to 1 MB
- get() of a missing key returns null, never throws
The caller relies on each of these promises. Pick a class to check them.
With LSP
Section titled “With LSP”Write the contract down, next to the interface. Turn it into one test suite, and run it against
every implementation. Then fix the store that fails. TinyKv stops claiming to be a BlobStore.
And an adapter, ChunkedStore, does the work to keep every promise on top of it.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
import java.nio.charset.StandardCharsets;import java.util.ArrayList;import java.util.Arrays;import java.util.HashMap;import java.util.List;import java.util.Map;import java.util.NoSuchElementException;
// The contract. Every caller relies on all three promises, so every BlobStore must keep them:// 1. get(key) right after put(key, data) returns the same bytes// 2. any size up to 1 MB is accepted// 3. get() of a missing key returns null; it never throwsinterface BlobStore { void put(String key, byte[] data); byte[] get(String key);}
class InMemoryStore implements BlobStore { private final Map<String, byte[]> blobs = new HashMap<>(); public void put(String key, byte[] data) { blobs.put(key, data.clone()); } public byte[] get(String key) { byte[] b = blobs.get(key); return b == null ? null : b.clone(); }}
// The cheap service, described honestly. It no longer claims to be a BlobStore, so its// 1 KB limit and its exception are its own business, visible in its own API.class TinyKv { static final int LIMIT = 1024; private final Map<String, byte[]> values = new HashMap<>();
void set(String key, byte[] value) { if (value.length > LIMIT) throw new IllegalArgumentException("value over 1 KB"); values.put(key, value.clone()); }
byte[] fetch(String key) { byte[] v = values.get(key); if (v == null) throw new NoSuchElementException(key); return v.clone(); }}
// An adapter that does the work to keep every BlobStore promise on top of TinyKv:// big blobs are split into 1 KB chunks, and "not found" becomes null.class ChunkedStore implements BlobStore { private final TinyKv kv;
ChunkedStore(TinyKv kv) { this.kv = kv; }
public void put(String key, byte[] data) { int chunks = (data.length + TinyKv.LIMIT - 1) / TinyKv.LIMIT; for (int i = 0; i < chunks; i++) { kv.set(key + "#" + i, Arrays.copyOfRange(data, i * TinyKv.LIMIT, Math.min(data.length, (i + 1) * TinyKv.LIMIT))); } // The manifest is written last: a reader never sees a count for chunks that aren't there yet. kv.set(key, Integer.toString(chunks).getBytes(StandardCharsets.UTF_8)); }
public byte[] get(String key) { int chunks; try { chunks = Integer.parseInt(new String(kv.fetch(key), StandardCharsets.UTF_8)); } catch (NoSuchElementException e) { return null; // the contract says missing is null, so the adapter translates } var out = new java.io.ByteArrayOutputStream(); for (int i = 0; i < chunks; i++) out.writeBytes(kv.fetch(key + "#" + i)); return out.toByteArray(); }}
class Contract { // One test suite, run against every implementation. A new BlobStore isn't done until it passes. static List<String> check(BlobStore store) { List<String> out = new ArrayList<>(); out.add(attempt("round trip", () -> { store.put("a", "hello".getBytes()); return Arrays.equals(store.get("a"), "hello".getBytes()); })); out.add(attempt("5 KB value", () -> { byte[] big = new byte[5_000]; Arrays.fill(big, (byte) 7); store.put("big", big); return Arrays.equals(store.get("big"), big); })); out.add(attempt("missing is null", () -> store.get("nope") == null)); return out; }
private static String attempt(String name, java.util.function.BooleanSupplier test) { try { return (test.getAsBoolean() ? "PASS " : "FAIL ") + name; } catch (RuntimeException e) { return "FAIL " + name + " (" + e.getClass().getSimpleName() + ")"; } }}
class Main { public static void main(String[] args) { System.out.println("InMemoryStore " + Contract.check(new InMemoryStore())); System.out.println("ChunkedStore " + Contract.check(new ChunkedStore(new TinyKv()))); }}from typing import Callable, Protocol
# The contract. Every caller relies on all three promises, so every BlobStore must keep them:# 1. get(key) right after put(key, data) returns the same bytes# 2. any size up to 1 MB is accepted# 3. get() of a missing key returns None; it never raisesclass BlobStore(Protocol): def put(self, key: str, data: bytes) -> None: ... def get(self, key: str) -> bytes | None: ...
class InMemoryStore: def __init__(self) -> None: self._blobs: dict[str, bytes] = {}
def put(self, key: str, data: bytes) -> None: self._blobs[key] = bytes(data)
def get(self, key: str) -> bytes | None: return self._blobs.get(key)
# The cheap service, described honestly. It no longer claims to be a BlobStore, so its# 1 KB limit and its KeyError are its own business, visible in its own API.class TinyKv: LIMIT = 1024
def __init__(self) -> None: self._values: dict[str, bytes] = {}
def set(self, key: str, value: bytes) -> None: if len(value) > self.LIMIT: raise ValueError("value over 1 KB") self._values[key] = bytes(value)
def fetch(self, key: str) -> bytes: return self._values[key] # KeyError when missing
# An adapter that does the work to keep every BlobStore promise on top of TinyKv:# big blobs are split into 1 KB chunks, and "not found" becomes None.class ChunkedStore: def __init__(self, kv: TinyKv) -> None: self._kv = kv
def put(self, key: str, data: bytes) -> None: size = TinyKv.LIMIT chunks = (len(data) + size - 1) // size for i in range(chunks): self._kv.set(f"{key}#{i}", data[i * size : (i + 1) * size]) # The manifest is written last: a reader never sees a count for chunks that aren't there yet. self._kv.set(key, str(chunks).encode())
def get(self, key: str) -> bytes | None: try: chunks = int(self._kv.fetch(key).decode()) except KeyError: return None # the contract says missing is None, so the adapter translates return b"".join(self._kv.fetch(f"{key}#{i}") for i in range(chunks))
# One test suite, run against every implementation. A new BlobStore isn't done until it passes.def check_contract(store: BlobStore) -> list[str]: def attempt(name: str, test: Callable[[], bool]) -> str: try: return ("PASS " if test() else "FAIL ") + name except Exception as e: return f"FAIL {name} ({type(e).__name__})"
def round_trip() -> bool: store.put("a", b"hello") return store.get("a") == b"hello"
def big_value() -> bool: big = bytes([7]) * 5_000 store.put("big", big) return store.get("big") == big
return [ attempt("round trip", round_trip), attempt("5 KB value", big_value), attempt("missing is None", lambda: store.get("nope") is None), ]
if __name__ == "__main__": print("InMemoryStore", check_contract(InMemoryStore())) print("ChunkedStore ", check_contract(ChunkedStore(TinyKv())))using System;using System.Collections.Generic;using System.Linq;using System.Text;
// The contract. Every caller relies on all three promises, so every IBlobStore must keep them:// 1. Get(key) right after Put(key, data) returns the same bytes// 2. any size up to 1 MB is accepted// 3. Get() of a missing key returns null; it never throwsinterface IBlobStore{ void Put(string key, byte[] data); byte[]? Get(string key);}
class InMemoryStore : IBlobStore{ private readonly Dictionary<string, byte[]> _blobs = new(); public void Put(string key, byte[] data) => _blobs[key] = (byte[])data.Clone(); public byte[]? Get(string key) => _blobs.TryGetValue(key, out var b) ? (byte[])b.Clone() : null;}
// The cheap service, described honestly. It no longer claims to be an IBlobStore, so its// 1 KB limit and its exception are its own business, visible in its own API.class TinyKv{ public const int Limit = 1024; private readonly Dictionary<string, byte[]> _values = new();
public void Set(string key, byte[] value) { if (value.Length > Limit) throw new ArgumentException("value over 1 KB"); _values[key] = (byte[])value.Clone(); }
public byte[] Fetch(string key) => (byte[])_values[key].Clone(); // KeyNotFoundException when missing}
// An adapter that does the work to keep every IBlobStore promise on top of TinyKv:// big blobs are split into 1 KB chunks, and "not found" becomes null.class ChunkedStore : IBlobStore{ private readonly TinyKv _kv;
public ChunkedStore(TinyKv kv) => _kv = kv;
public void Put(string key, byte[] data) { var chunks = (data.Length + TinyKv.Limit - 1) / TinyKv.Limit; for (var i = 0; i < chunks; i++) _kv.Set($"{key}#{i}", data.Skip(i * TinyKv.Limit).Take(TinyKv.Limit).ToArray()); // The manifest is written last: a reader never sees a count for chunks that aren't there yet. _kv.Set(key, Encoding.UTF8.GetBytes(chunks.ToString())); }
public byte[]? Get(string key) { int chunks; try { chunks = int.Parse(Encoding.UTF8.GetString(_kv.Fetch(key))); } catch (KeyNotFoundException) { return null; // the contract says missing is null, so the adapter translates } return Enumerable.Range(0, chunks).SelectMany(i => _kv.Fetch($"{key}#{i}")).ToArray(); }}
static class Contract{ // One test suite, run against every implementation. A new IBlobStore isn't done until it passes. public static List<string> Check(IBlobStore store) { var big = Enumerable.Repeat((byte)7, 5_000).ToArray(); return new List<string> { Attempt("round trip", () => { store.Put("a", "hello"u8.ToArray()); return store.Get("a")!.SequenceEqual("hello"u8.ToArray()); }), Attempt("5 KB value", () => { store.Put("big", big); return store.Get("big")!.SequenceEqual(big); }), Attempt("missing is null", () => store.Get("nope") is null), }; }
private static string Attempt(string name, Func<bool> test) { try { return (test() ? "PASS " : "FAIL ") + name; } catch (Exception e) { return $"FAIL {name} ({e.GetType().Name})"; } }}
static class Program{ static void Main() { Console.WriteLine("InMemoryStore " + string.Join(", ", Contract.Check(new InMemoryStore()))); Console.WriteLine("ChunkedStore " + string.Join(", ", Contract.Check(new ChunkedStore(new TinyKv())))); }}package main
import ( "bytes" "errors" "fmt" "strconv")
// BlobStore is the contract. Every caller relies on all three promises:// 1. Get(key) right after Put(key, data) returns the same bytes// 2. Put accepts any size up to 1 MB without an error// 3. Get of a missing key returns (nil, nil): missing is not an errortype BlobStore interface { Put(key string, data []byte) error Get(key string) ([]byte, error)}
type InMemoryStore struct{ blobs map[string][]byte }
func NewInMemoryStore() *InMemoryStore { return &InMemoryStore{map[string][]byte{}} }
func (s *InMemoryStore) Put(key string, data []byte) error { s.blobs[key] = bytes.Clone(data) return nil}
func (s *InMemoryStore) Get(key string) ([]byte, error) { return bytes.Clone(s.blobs[key]), nil }
// TinyKv is the cheap service, described honestly. It no longer claims to be a BlobStore,// so its 1 KB limit and its not-found error are its own business, visible in its own API.type TinyKv struct{ values map[string][]byte }
const tinyLimit = 1024
var ErrNotFound = errors.New("not found")
func (t *TinyKv) Set(key string, value []byte) error { if len(value) > tinyLimit { return errors.New("value over 1 KB") } t.values[key] = bytes.Clone(value) return nil}
func (t *TinyKv) Fetch(key string) ([]byte, error) { v, ok := t.values[key] if !ok { return nil, ErrNotFound } return bytes.Clone(v), nil}
// ChunkedStore is an adapter that does the work to keep every BlobStore promise on top of// TinyKv: big blobs are split into 1 KB chunks, and "not found" becomes (nil, nil).type ChunkedStore struct{ kv *TinyKv }
func (c ChunkedStore) Put(key string, data []byte) error { chunks := (len(data) + tinyLimit - 1) / tinyLimit for i := 0; i < chunks; i++ { end := min(len(data), (i+1)*tinyLimit) if err := c.kv.Set(key+"#"+strconv.Itoa(i), data[i*tinyLimit:end]); err != nil { return err } } // The manifest is written last: a reader never sees a count for chunks that aren't there yet. return c.kv.Set(key, []byte(strconv.Itoa(chunks)))}
func (c ChunkedStore) Get(key string) ([]byte, error) { manifest, err := c.kv.Fetch(key) if errors.Is(err, ErrNotFound) { return nil, nil // the contract says missing is not an error, so the adapter translates } if err != nil { return nil, err } chunks, err := strconv.Atoi(string(manifest)) if err != nil { return nil, err } var out []byte for i := 0; i < chunks; i++ { part, err := c.kv.Fetch(key + "#" + strconv.Itoa(i)) if err != nil { return nil, err } out = append(out, part...) } return out, nil}
// CheckContract is one test suite, run against every implementation. A new BlobStore isn't// done until it passes.func CheckContract(store BlobStore) []string { attempt := func(name string, test func() error) string { if err := test(); err != nil { return "FAIL " + name + " (" + err.Error() + ")" } return "PASS " + name } roundTrip := func(key string, data []byte) func() error { return func() error { if err := store.Put(key, data); err != nil { return err } got, err := store.Get(key) if err != nil { return err } if !bytes.Equal(got, data) { return errors.New("different bytes") } return nil } } return []string{ attempt("round trip", roundTrip("a", []byte("hello"))), attempt("5 KB value", roundTrip("big", bytes.Repeat([]byte{7}, 5_000))), attempt("missing is nil", func() error { got, err := store.Get("nope") if err != nil { return err } if got != nil { return errors.New("not nil") } return nil }), }}
func main() { fmt.Println("InMemoryStore", CheckContract(NewInMemoryStore())) fmt.Println("ChunkedStore ", CheckContract(ChunkedStore{&TinyKv{map[string][]byte{}}}))}// The contract. Every caller relies on all three promises, so every BlobStore must keep them:// 1. get(key) right after put(key, data) returns the same bytes// 2. any size up to 1 MB is accepted// 3. get() of a missing key returns undefined; it never throwsinterface BlobStore { put(key: string, data: Uint8Array): void; get(key: string): Uint8Array | undefined;}
class InMemoryStore implements BlobStore { private readonly blobs = new Map<string, Uint8Array>(); put(key: string, data: Uint8Array) { this.blobs.set(key, data.slice()); } get(key: string) { return this.blobs.get(key)?.slice(); }}
class NotFoundError extends Error {}
// The cheap service, described honestly. It no longer claims to be a BlobStore, so its// 1 KB limit and its exception are its own business, visible in its own API.class TinyKv { static readonly LIMIT = 1024; private readonly values = new Map<string, Uint8Array>();
set(key: string, value: Uint8Array): void { if (value.length > TinyKv.LIMIT) throw new RangeError("value over 1 KB"); this.values.set(key, value.slice()); }
fetch(key: string): Uint8Array { const v = this.values.get(key); if (!v) throw new NotFoundError(key); return v.slice(); }}
// An adapter that does the work to keep every BlobStore promise on top of TinyKv:// big blobs are split into 1 KB chunks, and "not found" becomes undefined.class ChunkedStore implements BlobStore { constructor(private readonly kv: TinyKv) {}
put(key: string, data: Uint8Array): void { const size = TinyKv.LIMIT; const chunks = Math.ceil(data.length / size); for (let i = 0; i < chunks; i++) this.kv.set(`${key}#${i}`, data.subarray(i * size, (i + 1) * size)); // The manifest is written last: a reader never sees a count for chunks that aren't there yet. this.kv.set(key, new TextEncoder().encode(String(chunks))); }
get(key: string): Uint8Array | undefined { let chunks: number; try { chunks = Number(new TextDecoder().decode(this.kv.fetch(key))); } catch (e) { if (e instanceof NotFoundError) return undefined; // the contract says missing is undefined throw e; } const parts = Array.from({ length: chunks }, (_, i) => this.kv.fetch(`${key}#${i}`)); const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0)); let at = 0; for (const p of parts) (out.set(p, at), (at += p.length)); return out; }}
const same = (a: Uint8Array | undefined, b: Uint8Array) => !!a && a.length === b.length && a.every((x, i) => x === b[i]);
// One test suite, run against every implementation. A new BlobStore isn't done until it passes.function checkContract(store: BlobStore): string[] { const attempt = (name: string, test: () => boolean) => { try { return (test() ? "PASS " : "FAIL ") + name; } catch (e) { return `FAIL ${name} (${(e as Error).name})`; } }; const hello = new TextEncoder().encode("hello"); const big = new Uint8Array(5_000).fill(7); return [ attempt("round trip", () => (store.put("a", hello), same(store.get("a"), hello))), attempt("5 KB value", () => (store.put("big", big), same(store.get("big"), big))), attempt("missing is undefined", () => store.get("nope") === undefined), ];}
console.log("InMemoryStore", checkContract(new InMemoryStore()));console.log("ChunkedStore ", checkContract(new ChunkedStore(new TinyKv())));What to notice
Section titled “What to notice”- A contract nobody wrote down can’t be checked. The three promises are now comments on the
interface and, more importantly, tests. A new store isn’t done until
Contract.checkpasses. - One suite, every implementation. The same tests run against the in-memory store used in unit tests and the real one. That’s how teams catch “works locally, fails in production” before it ships.
- Not everything with the right methods should implement the interface.
TinyKvhas real limits. Instead of hiding them behindBlobStore, it shows them in its own API, where callers can see them. - Adapters restore substitutability.
ChunkedStoresplits big values and turns “not found” intonull. Writing the manifest last means a reader never sees a chunk count before the chunks exist.
Our tests use an in-memory fake of the database, and everything passes. In production, a read right after a write sometimes returns stale data. What went wrong in the design?
Whether you name it as a substitution failure: the fake promises read-after-write, and the replica-backed store doesn’t. Then whether you fix the contract, not the symptom. Decide whether the interface promises it. Run one contract suite against both. Route reads that need it to the primary. The red flag is “add a sleep”.
A rate limiter's counters
- Promises about time and threads
- Weakened postconditions
- Changing the contract so it can be kept
An API allows each user a few requests per minute. The rate limiter asks a CounterStore to count
requests per user, and refuses once the count passes the limit. Memory grew with every user who ever
called the API. So someone swapped in a bounded store, which keeps the most recently used keys and
evicts the rest. Same interface. Every test still compiles. Memory is flat.
And the rate limit stops working. This level is about promises no type can express, about memory and threads, and about the fix being a change to the contract itself.
Without the principle
Section titled “Without the principle”import java.util.LinkedHashMap;import java.util.Map;import java.util.concurrent.ConcurrentHashMap;import java.util.concurrent.atomic.AtomicLong;
// The contract, including the parts no type can express:// - increment(key) returns how many times increment(key) has been called// - it never forgets a count// - it is safe to call from many threads at onceinterface CounterStore { long increment(String key);}
class ExactCounterStore implements CounterStore { private final Map<String, AtomicLong> counts = new ConcurrentHashMap<>(); public long increment(String key) { return counts.computeIfAbsent(key, k -> new AtomicLong()).incrementAndGet(); }}
// Memory grew with every user, so someone swapped in a bounded version: it keeps the// most recently used keys and evicts the rest. Same interface, every test still compiles.// flaw: it weakens a postcondition. An evicted key starts again from zero, so "never// forgets a count" is no longer true, and no caller was told.class BoundedCounterStore implements CounterStore { private final Map<String, Long> counts;
BoundedCounterStore(int capacity) { counts = new LinkedHashMap<>(16, 0.75f, true) { protected boolean removeEldestEntry(Map.Entry<String, Long> eldest) { return size() > capacity; } }; }
public synchronized long increment(String key) { return counts.merge(key, 1L, Long::sum); }}
// Correct for any CounterStore that keeps the contract. It can't defend against one that doesn't.class RateLimiter { private final CounterStore store; private final int limitPerMinute;
RateLimiter(CounterStore store, int limitPerMinute) { this.store = store; this.limitPerMinute = limitPerMinute; }
boolean allow(String user, long minute) { return store.increment(user + "@" + minute) <= limitPerMinute; }}
class Main { static int mallorysRequestsAllowed(CounterStore store) { RateLimiter limiter = new RateLimiter(store, 3); int allowed = 0; // Three users take turns within one minute. With room for only two keys, each one is // evicted just before it is needed again, so every request looks like the first. for (int round = 0; round < 6; round++) { limiter.allow("alice", 0); limiter.allow("bob", 0); if (limiter.allow("mallory", 0)) allowed++; } return allowed; }
public static void main(String[] args) { System.out.println("exact: mallory allowed " + mallorysRequestsAllowed(new ExactCounterStore()) + " of 6"); // 3 System.out.println("bounded: mallory allowed " + mallorysRequestsAllowed(new BoundedCounterStore(2)) + " of 6"); // 6 }}import threadingfrom collections import OrderedDictfrom typing import Protocol
# The contract, including the parts no type can express:# - increment(key) returns how many times increment(key) has been called# - it never forgets a count# - it is safe to call from many threads at onceclass CounterStore(Protocol): def increment(self, key: str) -> int: ...
class ExactCounterStore: def __init__(self) -> None: self._counts: dict[str, int] = {} self._lock = threading.Lock()
def increment(self, key: str) -> int: with self._lock: self._counts[key] = self._counts.get(key, 0) + 1 return self._counts[key]
# Memory grew with every user, so someone swapped in a bounded version: it keeps the# most recently used keys and evicts the rest. Same protocol, the type checker is happy.# flaw: it weakens a postcondition. An evicted key starts again from zero, so "never# forgets a count" is no longer true, and no caller was told.class BoundedCounterStore: def __init__(self, capacity: int) -> None: self._counts: OrderedDict[str, int] = OrderedDict() self._capacity = capacity self._lock = threading.Lock()
def increment(self, key: str) -> int: with self._lock: self._counts[key] = self._counts.get(key, 0) + 1 self._counts.move_to_end(key) if len(self._counts) > self._capacity: self._counts.popitem(last=False) return self._counts.get(key, 1)
# Correct for any CounterStore that keeps the contract. It can't defend against one that doesn't.class RateLimiter: def __init__(self, store: CounterStore, limit_per_minute: int) -> None: self._store = store self._limit = limit_per_minute
def allow(self, user: str, minute: int) -> bool: return self._store.increment(f"{user}@{minute}") <= self._limit
def mallorys_requests_allowed(store: CounterStore) -> int: limiter = RateLimiter(store, 3) allowed = 0 # Three users take turns within one minute. With room for only two keys, each one is # evicted just before it is needed again, so every request looks like the first. for _ in range(6): limiter.allow("alice", 0) limiter.allow("bob", 0) allowed += limiter.allow("mallory", 0) return allowed
if __name__ == "__main__": print("exact: mallory allowed", mallorys_requests_allowed(ExactCounterStore()), "of 6") # 3 print("bounded: mallory allowed", mallorys_requests_allowed(BoundedCounterStore(2)), "of 6") # 6using System;using System.Collections.Concurrent;using System.Collections.Generic;using System.Threading;
// The contract, including the parts no type can express:// - Increment(key) returns how many times Increment(key) has been called// - it never forgets a count// - it is safe to call from many threads at onceinterface ICounterStore{ long Increment(string key);}
class ExactCounterStore : ICounterStore{ private readonly ConcurrentDictionary<string, long> _counts = new(); public long Increment(string key) => _counts.AddOrUpdate(key, 1, (_, n) => n + 1);}
// Memory grew with every user, so someone swapped in a bounded version: it keeps the// most recently used keys and evicts the rest. Same interface, every test still compiles.// flaw: it weakens a postcondition. An evicted key starts again from zero, so "never// forgets a count" is no longer true, and no caller was told.class BoundedCounterStore : ICounterStore{ private readonly int _capacity; private readonly Dictionary<string, LinkedListNode<(string Key, long Count)>> _index = new(); private readonly LinkedList<(string Key, long Count)> _order = new(); // most recent first private readonly object _lock = new();
public BoundedCounterStore(int capacity) => _capacity = capacity;
public long Increment(string key) { lock (_lock) { long count = 1; if (_index.TryGetValue(key, out var node)) { count = node.Value.Count + 1; _order.Remove(node); } _index[key] = _order.AddFirst((key, count)); if (_order.Count > _capacity) { _index.Remove(_order.Last!.Value.Key); _order.RemoveLast(); } return count; } }}
// Correct for any ICounterStore that keeps the contract. It can't defend against one that doesn't.class RateLimiter{ private readonly ICounterStore _store; private readonly int _limitPerMinute;
public RateLimiter(ICounterStore store, int limitPerMinute) { _store = store; _limitPerMinute = limitPerMinute; }
public bool Allow(string user, long minute) => _store.Increment($"{user}@{minute}") <= _limitPerMinute;}
static class Program{ static int MallorysRequestsAllowed(ICounterStore store) { var limiter = new RateLimiter(store, 3); var allowed = 0; // Three users take turns within one minute. With room for only two keys, each one is // evicted just before it is needed again, so every request looks like the first. for (var round = 0; round < 6; round++) { limiter.Allow("alice", 0); limiter.Allow("bob", 0); if (limiter.Allow("mallory", 0)) allowed++; } return allowed; }
static void Main() { Console.WriteLine($"exact: mallory allowed {MallorysRequestsAllowed(new ExactCounterStore())} of 6"); // 3 Console.WriteLine($"bounded: mallory allowed {MallorysRequestsAllowed(new BoundedCounterStore(2))} of 6"); // 6 }}package main
import ( "container/list" "fmt" "sync")
// CounterStore is the contract, including the parts no type can express:// - Increment(key) returns how many times Increment(key) has been called// - it never forgets a count// - it is safe to call from many goroutines at oncetype CounterStore interface { Increment(key string) int64}
type ExactCounterStore struct { mu sync.Mutex counts map[string]int64}
func NewExactCounterStore() *ExactCounterStore { return &ExactCounterStore{counts: map[string]int64{}}}
func (s *ExactCounterStore) Increment(key string) int64 { s.mu.Lock() defer s.mu.Unlock() s.counts[key]++ return s.counts[key]}
// BoundedCounterStore was swapped in when memory grew with every user: it keeps the most// recently used keys and evicts the rest. Same interface, everything still compiles.// flaw: it weakens a postcondition. An evicted key starts again from zero, so "never// forgets a count" is no longer true, and no caller was told.type BoundedCounterStore struct { mu sync.Mutex capacity int order *list.List // most recent at the front; values are *entry index map[string]*list.Element}
type entry struct { key string count int64}
func NewBoundedCounterStore(capacity int) *BoundedCounterStore { return &BoundedCounterStore{capacity: capacity, order: list.New(), index: map[string]*list.Element{}}}
func (s *BoundedCounterStore) Increment(key string) int64 { s.mu.Lock() defer s.mu.Unlock() if el, ok := s.index[key]; ok { el.Value.(*entry).count++ s.order.MoveToFront(el) return el.Value.(*entry).count } s.index[key] = s.order.PushFront(&entry{key, 1}) if s.order.Len() > s.capacity { oldest := s.order.Remove(s.order.Back()).(*entry) delete(s.index, oldest.key) } return 1}
// RateLimiter is correct for any CounterStore that keeps the contract. It can't defend// against one that doesn't.type RateLimiter struct { Store CounterStore LimitPerMinute int64}
func (l RateLimiter) Allow(user string, minute int64) bool { return l.Store.Increment(fmt.Sprintf("%s@%d", user, minute)) <= l.LimitPerMinute}
func mallorysRequestsAllowed(store CounterStore) int { limiter := RateLimiter{store, 3} allowed := 0 // Three users take turns within one minute. With room for only two keys, each one is // evicted just before it is needed again, so every request looks like the first. for round := 0; round < 6; round++ { limiter.Allow("alice", 0) limiter.Allow("bob", 0) if limiter.Allow("mallory", 0) { allowed++ } } return allowed}
func main() { fmt.Println("exact: mallory allowed", mallorysRequestsAllowed(NewExactCounterStore()), "of 6") // 3 fmt.Println("bounded: mallory allowed", mallorysRequestsAllowed(NewBoundedCounterStore(2)), "of 6") // 6}// The contract, including the parts no type can express:// - increment(key) returns how many times increment(key) has been called// - it never forgets a countinterface CounterStore { increment(key: string): number;}
class ExactCounterStore implements CounterStore { private readonly counts = new Map<string, number>(); increment(key: string) { const n = (this.counts.get(key) ?? 0) + 1; this.counts.set(key, n); return n; }}
// Memory grew with every user, so someone swapped in a bounded version: it keeps the// most recently used keys and evicts the rest. Same interface, everything still compiles.// flaw: it weakens a postcondition. An evicted key starts again from zero, so "never// forgets a count" is no longer true, and no caller was told.class BoundedCounterStore implements CounterStore { // A Map remembers insertion order; deleting and re-inserting moves a key to the end. private readonly counts = new Map<string, number>(); constructor(private readonly capacity: number) {}
increment(key: string) { const n = (this.counts.get(key) ?? 0) + 1; this.counts.delete(key); this.counts.set(key, n); if (this.counts.size > this.capacity) this.counts.delete(this.counts.keys().next().value!); return n; }}
// Correct for any CounterStore that keeps the contract. It can't defend against one that doesn't.class RateLimiter { constructor( private readonly store: CounterStore, private readonly limitPerMinute: number, ) {}
allow(user: string, minute: number): boolean { return this.store.increment(`${user}@${minute}`) <= this.limitPerMinute; }}
function mallorysRequestsAllowed(store: CounterStore): number { const limiter = new RateLimiter(store, 3); let allowed = 0; // Three users take turns within one minute. With room for only two keys, each one is // evicted just before it is needed again, so every request looks like the first. for (let round = 0; round < 6; round++) { limiter.allow("alice", 0); limiter.allow("bob", 0); if (limiter.allow("mallory", 0)) allowed++; } return allowed;}
console.log("exact: mallory allowed", mallorysRequestsAllowed(new ExactCounterStore()), "of 6"); // 3console.log("bounded: mallory allowed", mallorysRequestsAllowed(new BoundedCounterStore(2)), "of 6"); // 6The bounded store weakens a postcondition. “Returns how many times this key was counted”
becomes “…unless it was evicted”. Say there’s room for two keys, and three users take turns. Each
user is evicted just before their next request. Every request looks like the first, and mallory
gets 6 requests through a limit of 3. At real scale, an attacker can do the same, by rotating
through enough fake keys. They push their own count out of the cache, and the limit never applies.
Check each store against what the rate limiter relies on:
RateLimiter.allow(user, minute)- counts every call
- never forgets a count in the current minute
- safe from many threads at once
- memory stays bounded
The caller relies on each of these promises. Pick a class to check them.
With LSP
Section titled “With LSP”The real problem: the interface made a promise (“never forgets”) that a memory-bounded store can’t keep. So change the contract to one that can be kept and is still enough for the caller. A rate limiter only needs counts for the current window. Make the window part of the interface, promise never to forget the newest window, and allow older ones to be dropped.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
import java.util.HashMap;import java.util.Map;
// The contract, now naming the time window so a bounded store can keep it:// - increment(key, window) returns how many times it has been called for that key and window// - counts for the newest window it has seen are never forgotten; older windows may be dropped// - it is safe to call from many threads at once// A promise a store can't keep is a promise the interface shouldn't make. This one can be kept// with bounded memory, because a rate limiter only ever needs the current window.interface CounterStore { long increment(String key, long window);}
// Bounded by design: when a newer window starts, all older counts are dropped together.// Memory is one window's worth of users, and nothing the limiter still needs is forgotten.class WindowedCounterStore implements CounterStore { private long current = Long.MIN_VALUE; private Map<String, Long> counts = new HashMap<>();
// One lock keeps it simple and correct. A busier service would shard by key. public synchronized long increment(String key, long window) { if (window > current) { current = window; counts = new HashMap<>(); } if (window < current) return Long.MAX_VALUE; // a late request for a closed window: never allow it return counts.merge(key, 1L, Long::sum); }
synchronized int keysHeld() { return counts.size(); }}
class RateLimiter { private final CounterStore store; private final int limitPerMinute;
RateLimiter(CounterStore store, int limitPerMinute) { this.store = store; this.limitPerMinute = limitPerMinute; }
boolean allow(String user, long minute) { return store.increment(user, minute) <= limitPerMinute; }}
class Contract { // Run against every CounterStore. The thread test is part of the contract, not an extra. static String check(CounterStore store) throws InterruptedException { StringBuilder out = new StringBuilder(); store.increment("k", 1); store.increment("k", 1); out.append(store.increment("k", 1) == 3 ? "PASS" : "FAIL").append(" counts every call; ");
store.increment("first", 1); for (int i = 0; i < 10_000; i++) store.increment("user" + i, 1); out.append(store.increment("first", 1) == 2 ? "PASS" : "FAIL").append(" survives 10,000 other keys; ");
Thread[] threads = new Thread[8]; for (int t = 0; t < threads.length; t++) { threads[t] = new Thread(() -> { for (int i = 0; i < 1_000; i++) store.increment("hot", 1); }); threads[t].start(); } for (Thread t : threads) t.join(); out.append(store.increment("hot", 1) == 8_001 ? "PASS" : "FAIL").append(" safe from 8 threads"); return out.toString(); }}
class Main { public static void main(String[] args) throws InterruptedException { System.out.println(Contract.check(new WindowedCounterStore()));
WindowedCounterStore store = new WindowedCounterStore(); RateLimiter limiter = new RateLimiter(store, 3); int allowed = 0; for (int round = 0; round < 6; round++) { limiter.allow("alice", 0); limiter.allow("bob", 0); if (limiter.allow("mallory", 0)) allowed++; } System.out.println("mallory allowed " + allowed + " of 6"); // 3 limiter.allow("alice", 1); // a new minute starts System.out.println("keys held after minute 1 starts: " + store.keysHeld()); // 1 }}import threadingfrom typing import Protocol
# The contract, now naming the time window so a bounded store can keep it:# - increment(key, window) returns how many times it has been called for that key and window# - counts for the newest window it has seen are never forgotten; older windows may be dropped# - it is safe to call from many threads at once# A promise a store can't keep is a promise the interface shouldn't make. This one can be kept# with bounded memory, because a rate limiter only ever needs the current window.class CounterStore(Protocol): def increment(self, key: str, window: int) -> int: ...
# Bounded by design: when a newer window starts, all older counts are dropped together.# Memory is one window's worth of users, and nothing the limiter still needs is forgotten.class WindowedCounterStore: def __init__(self) -> None: self._current: int | None = None self._counts: dict[str, int] = {} self._lock = threading.Lock() # one lock keeps it simple; a busier service would shard
def increment(self, key: str, window: int) -> int: with self._lock: if self._current is None or window > self._current: self._current = window self._counts = {} if window < self._current: return 2**63 - 1 # a late request for a closed window: never allow it self._counts[key] = self._counts.get(key, 0) + 1 return self._counts[key]
def keys_held(self) -> int: with self._lock: return len(self._counts)
class RateLimiter: def __init__(self, store: CounterStore, limit_per_minute: int) -> None: self._store = store self._limit = limit_per_minute
def allow(self, user: str, minute: int) -> bool: return self._store.increment(user, minute) <= self._limit
# Run against every CounterStore. The thread test is part of the contract, not an extra.def check_contract(store: CounterStore) -> str: results = [] store.increment("k", 1) store.increment("k", 1) results.append(("PASS" if store.increment("k", 1) == 3 else "FAIL") + " counts every call")
store.increment("first", 1) for i in range(10_000): store.increment(f"user{i}", 1) results.append(("PASS" if store.increment("first", 1) == 2 else "FAIL") + " survives 10,000 other keys")
def hammer() -> None: for _ in range(1_000): store.increment("hot", 1)
threads = [threading.Thread(target=hammer) for _ in range(8)] for t in threads: t.start() for t in threads: t.join() results.append(("PASS" if store.increment("hot", 1) == 8_001 else "FAIL") + " safe from 8 threads") return "; ".join(results)
if __name__ == "__main__": print(check_contract(WindowedCounterStore()))
store = WindowedCounterStore() limiter = RateLimiter(store, 3) allowed = 0 for _ in range(6): limiter.allow("alice", 0) limiter.allow("bob", 0) allowed += limiter.allow("mallory", 0) print("mallory allowed", allowed, "of 6") # 3 limiter.allow("alice", 1) # a new minute starts print("keys held after minute 1 starts:", store.keys_held()) # 1using System;using System.Collections.Generic;using System.Threading.Tasks;
// The contract, now naming the time window so a bounded store can keep it:// - Increment(key, window) returns how many times it has been called for that key and window// - counts for the newest window it has seen are never forgotten; older windows may be dropped// - it is safe to call from many threads at once// A promise a store can't keep is a promise the interface shouldn't make. This one can be kept// with bounded memory, because a rate limiter only ever needs the current window.interface ICounterStore{ long Increment(string key, long window);}
// Bounded by design: when a newer window starts, all older counts are dropped together.// Memory is one window's worth of users, and nothing the limiter still needs is forgotten.class WindowedCounterStore : ICounterStore{ private readonly object _lock = new(); // one lock keeps it simple; a busier service would shard private long _current = long.MinValue; private Dictionary<string, long> _counts = new();
public long Increment(string key, long window) { lock (_lock) { if (window > _current) { _current = window; _counts = new Dictionary<string, long>(); } if (window < _current) return long.MaxValue; // a late request for a closed window: never allow it _counts[key] = _counts.GetValueOrDefault(key) + 1; return _counts[key]; } }
public int KeysHeld { get { lock (_lock) return _counts.Count; } }}
class RateLimiter{ private readonly ICounterStore _store; private readonly int _limitPerMinute;
public RateLimiter(ICounterStore store, int limitPerMinute) { _store = store; _limitPerMinute = limitPerMinute; }
public bool Allow(string user, long minute) => _store.Increment(user, minute) <= _limitPerMinute;}
static class Contract{ // Run against every ICounterStore. The thread test is part of the contract, not an extra. public static string Check(ICounterStore store) { var results = new List<string>(); store.Increment("k", 1); store.Increment("k", 1); results.Add((store.Increment("k", 1) == 3 ? "PASS" : "FAIL") + " counts every call");
store.Increment("first", 1); for (var i = 0; i < 10_000; i++) store.Increment($"user{i}", 1); results.Add((store.Increment("first", 1) == 2 ? "PASS" : "FAIL") + " survives 10,000 other keys");
Parallel.For(0, 8, _ => { for (var i = 0; i < 1_000; i++) store.Increment("hot", 1); }); results.Add((store.Increment("hot", 1) == 8_001 ? "PASS" : "FAIL") + " safe from 8 threads"); return string.Join("; ", results); }}
static class Program{ static void Main() { Console.WriteLine(Contract.Check(new WindowedCounterStore()));
var store = new WindowedCounterStore(); var limiter = new RateLimiter(store, 3); var allowed = 0; for (var round = 0; round < 6; round++) { limiter.Allow("alice", 0); limiter.Allow("bob", 0); if (limiter.Allow("mallory", 0)) allowed++; } Console.WriteLine($"mallory allowed {allowed} of 6"); // 3 limiter.Allow("alice", 1); // a new minute starts Console.WriteLine($"keys held after minute 1 starts: {store.KeysHeld}"); // 1 }}package main
import ( "fmt" "math" "sync")
// CounterStore is the contract, now naming the time window so a bounded store can keep it:// - Increment(key, window) returns how many times it has been called for that key and window// - counts for the newest window it has seen are never forgotten; older windows may be dropped// - it is safe to call from many goroutines at once//// A promise a store can't keep is a promise the interface shouldn't make. This one can be kept// with bounded memory, because a rate limiter only ever needs the current window.type CounterStore interface { Increment(key string, window int64) int64}
// WindowedCounterStore is bounded by design: when a newer window starts, all older counts are// dropped together. Memory is one window's worth of users, and nothing still needed is forgotten.type WindowedCounterStore struct { mu sync.Mutex // one lock keeps it simple; a busier service would shard by key current int64 started bool counts map[string]int64}
func (s *WindowedCounterStore) Increment(key string, window int64) int64 { s.mu.Lock() defer s.mu.Unlock() if !s.started || window > s.current { s.current, s.started = window, true s.counts = map[string]int64{} } if window < s.current { return math.MaxInt64 // a late request for a closed window: never allow it } s.counts[key]++ return s.counts[key]}
func (s *WindowedCounterStore) KeysHeld() int { s.mu.Lock() defer s.mu.Unlock() return len(s.counts)}
type RateLimiter struct { Store CounterStore LimitPerMinute int64}
func (l RateLimiter) Allow(user string, minute int64) bool { return l.Store.Increment(user, minute) <= l.LimitPerMinute}
// CheckContract runs against every CounterStore. The goroutine test is part of the contract.func CheckContract(store CounterStore) []string { verdict := func(ok bool, name string) string { if ok { return "PASS " + name } return "FAIL " + name } var out []string store.Increment("k", 1) store.Increment("k", 1) out = append(out, verdict(store.Increment("k", 1) == 3, "counts every call"))
store.Increment("first", 1) for i := 0; i < 10_000; i++ { store.Increment(fmt.Sprint("user", i), 1) } out = append(out, verdict(store.Increment("first", 1) == 2, "survives 10,000 other keys"))
var wg sync.WaitGroup for g := 0; g < 8; g++ { wg.Add(1) go func() { defer wg.Done() for i := 0; i < 1_000; i++ { store.Increment("hot", 1) } }() } wg.Wait() out = append(out, verdict(store.Increment("hot", 1) == 8_001, "safe from 8 goroutines")) return out}
func main() { fmt.Println(CheckContract(&WindowedCounterStore{}))
store := &WindowedCounterStore{} limiter := RateLimiter{store, 3} allowed := 0 for round := 0; round < 6; round++ { limiter.Allow("alice", 0) limiter.Allow("bob", 0) if limiter.Allow("mallory", 0) { allowed++ } } fmt.Println("mallory allowed", allowed, "of 6") // 3 limiter.Allow("alice", 1) // a new minute starts fmt.Println("keys held after minute 1 starts:", store.KeysHeld()) // 1}// The contract, now naming the time window so a bounded store can keep it:// - increment(key, window) returns how many times it has been called for that key and window// - counts for the newest window it has seen are never forgotten; older windows may be dropped// A promise a store can't keep is a promise the interface shouldn't make. This one can be kept// with bounded memory, because a rate limiter only ever needs the current window.// (A Node process runs one call at a time, so there is no thread-safety clause here. A store// shared across processes, like Redis, would need its increment to be atomic.)interface CounterStore { increment(key: string, window: number): number;}
// Bounded by design: when a newer window starts, all older counts are dropped together.// Memory is one window's worth of users, and nothing the limiter still needs is forgotten.class WindowedCounterStore implements CounterStore { private current = -Infinity; private counts = new Map<string, number>();
increment(key: string, window: number): number { if (window > this.current) { this.current = window; this.counts = new Map(); } if (window < this.current) return Infinity; // a late request for a closed window: never allow it const n = (this.counts.get(key) ?? 0) + 1; this.counts.set(key, n); return n; }
get keysHeld(): number { return this.counts.size; }}
class RateLimiter { constructor( private readonly store: CounterStore, private readonly limitPerMinute: number, ) {}
allow(user: string, minute: number): boolean { return this.store.increment(user, minute) <= this.limitPerMinute; }}
// Run against every CounterStore.function checkContract(store: CounterStore): string[] { const verdict = (ok: boolean, name: string) => (ok ? "PASS " : "FAIL ") + name; store.increment("k", 1); store.increment("k", 1); const counts = verdict(store.increment("k", 1) === 3, "counts every call"); store.increment("first", 1); for (let i = 0; i < 10_000; i++) store.increment(`user${i}`, 1); const survives = verdict(store.increment("first", 1) === 2, "survives 10,000 other keys"); return [counts, survives];}
console.log(checkContract(new WindowedCounterStore()));
const store = new WindowedCounterStore();const limiter = new RateLimiter(store, 3);let allowed = 0;for (let round = 0; round < 6; round++) { limiter.allow("alice", 0); limiter.allow("bob", 0); if (limiter.allow("mallory", 0)) allowed++;}console.log("mallory allowed", allowed, "of 6"); // 3limiter.allow("alice", 1); // a new minute startsconsole.log("keys held after minute 1 starts:", store.keysHeld); // 1What to notice
Section titled “What to notice”- The best promises are the ones every implementation can keep. “Never forget anything” forced a choice between unbounded memory and a broken limiter. “Never forget the current window” is all the caller needs, and a bounded store can keep it.
- Thread safety is part of the contract. Many threads call
incrementat once. A store without a lock compiles, passes single-threaded tests, and under load lets two requests read 4 and both write 5. The contract suite includes a test with 8 threads for exactly that reason. - Late callers get a safe answer. A request stamped with a minute that has already ended gets “over the limit”, never a fresh count. Failing closed is part of the promise.
- An LRU cache is fine for a cache. Evicting is correct when the value can be recomputed, like a cached web page. It’s wrong for a counter, where the value is the history. Don’t put both behind one interface.
- Shared stores need the same promises. In production the counts live in Redis so every
server sees them. Redis’s
INCRis atomic and its keys can expire with the window, which is exactly this contract.
We moved the rate limiter’s counts into an LRU cache to save memory. Now some users get far more requests through than the limit. Why?
Whether you trace it to the contract: the limiter assumed counts are never forgotten, and the cache forgets them. Then whether you can redesign: counts per time window, drop whole windows when they end, and note that an attacker can force evictions. Saying “make the cache bigger” is the red flag.
The limiter only needs counts for the current minute, so that’s what the interface promises. The store drops old minutes as they end, which keeps memory bounded without breaking the limit.
Tradeoffs
Section titled “Tradeoffs”Be strict about substitution when:
- More than one implementation exists, or will: fakes in tests count.
- Implementations are written by other teams, partners or open-source contributors.
- The interface hides something with real-world limits: storage, networks, clocks, money.
Be relaxed when one class has one subclass inside one module, and both are changed together.
And when a hierarchy keeps needing exceptions, consider dropping inheritance altogether: a
sealed set of types with a switch makes every case visible.
Commonly taught wrong
Section titled “Commonly taught wrong”Commonly confused with
Section titled “Commonly confused with”| Idea | Looks like LSP because | The difference |
|---|---|---|
| Type checking | Both decide what can be passed where | The type checker checks signatures. LSP is about behavior the checker can’t see. |
| Interface segregation | Both split interfaces | ISP splits so callers depend on less. LSP splits so implementers can keep what they claim. The tag example does both. |
| Design by contract | Both talk about preconditions and postconditions | Design by contract (Meyer, Eiffel) is how you write the promises down. LSP is the rule for subtypes: keep them. |
| Covariance and contravariance | Both decide which subtypes are allowed | Those are the signature rules (wider parameters, narrower returns). LSP includes them and adds the behavioral ones. |
Where it shows up
Section titled “Where it shows up”On this site:
- LRU cache: every
EvictionPolicymust keep the cache’s capacity promise, whichever entry it chooses. - Rate limiter: every
Bucket, token bucket or fixed window, must keep the same promise: never let more than the limit through. - Parking lot: every
PricingPolicymust return a fee for any ticket, never throw “not supported” for a vehicle type.
In real systems, you’ll see LSP problems in JDBC drivers that each read the spec slightly differently, and in collection libraries with “optional” operations. Also in ORMs whose in-memory test mode behaves differently from the real database, and in cloud services sold as “S3-compatible”.
Interview questions
Section titled “Interview questions”Interviewer asksHow do you know whether a subclass is a safe substitute?
Write down what callers rely on: inputs accepted, results guaranteed, failures, thread safety. Turn that into tests against the parent type and run them against every subclass. If a subclass needs a stricter input, gives a weaker guarantee or throws something new, it isn’t a substitute.
Interviewer asksA subclass can't support one of the parent's methods. What do you do?
Don’t make it throw. Split the interface so the capability is separate (ReadableTags and
EditableTags), and have the subclass implement only what it can. Callers that need the
capability ask for that interface, and the compiler stops the wrong class being passed.
Interviewer asksCan a subtype ever throw an exception the parent doesn't?
Only one the parent’s contract already allows, or a subtype of it. A FileStore can throw
IOException if Store.save is declared to throw it. A brand-new failure, like “not supported”
or “too large”, is one no caller is prepared for.
Interviewer asksIs Square a subtype of Rectangle?
It depends on what Rectangle promises. If rectangles are mutable and setWidth promises not to
touch the height, no: a square must break that. If rectangles are immutable values, yes. The
answer comes from the contract, not the geometry.
| Decision | We chose | We gave up |
|---|---|---|
| What a type means | Its written promises, not only its signatures | Interfaces you can implement without reading anything |
| A class that can’t keep a promise | Don’t claim the type; split the interface or adapt the class | One interface for everything with similar methods |
| How substitution is enforced | One contract test suite run against every implementation | Time spent writing tests for “obvious” behavior |
| A promise nobody can keep cheaply | Change the contract to what callers need | A stronger guarantee nobody can afford |
| Test fakes | No stronger than the real implementation | Fakes that are simpler than the real thing |
Further reading
Section titled “Further reading”- Open/closed, which depends on substitution to be safe
- Interface segregation, the usual fix for subtypes that can’t keep every promise
- Rate limiter, for the full design behind the hard example
- Barbara Liskov and Jeannette Wing, A Behavioral Notion of Subtyping (1994)
- Barbara Liskov, Data Abstraction and Hierarchy, OOPSLA keynote (1987)
The designbeyondcode digest, every Saturday
New deep dives, interview tips and site news in one short email. Free. Leave with one click.
We never sell or share your email. We only use it to send this newsletter.