On This Page
What the shortener prints when it's done
Run the same program three times with different arguments and you get three different code generators and two different storage back ends, without a single if in the service that does the work:
generator=sequential storage=memory
G9 -> https://ggorantala.dev/how-to-use-java-records/
GA -> https://ggorantala.dev/how-to-use-optional-java/
clicks G9 = 2
clicks GA = 1
unknown code -> Optional.empty
generator=hash storage=file
1CMqNAHRO -> https://ggorantala.dev/how-to-use-java-records/
1DQhMUpnP -> https://ggorantala.dev/how-to-use-optional-java/
clicks 1CMqNAHRO = 2
clicks 1DQhMUpnP = 1
unknown code -> Optional.empty
That's the factory pattern in Java doing its job: the caller says what it wants ("hash", "file") and a factory decides which class to build. We'll build a small URL shortener and use three flavours of the pattern on it: a static factory method, a registry factory that picks a class by name, and an abstract factory that produces a matching family of objects (a link store and a click log that must agree on where data lives).
This is for you if you've seen new scattered through your code and want to know when pulling it into a factory pays off, and when it's just ceremony. You need to be comfortable with interfaces and records. Everything runs on a bare JDK, so there's nothing to install beyond Java.
Main ──> CodeGenerators.create("hash") ──> CodeGenerator (factory with registry)
│
└───> StorageFactory.fromConfig(...) ──> StorageFactory (abstract factory)
├─ linkStore() ──> LinkStore
└─ clickLog() ──> ClickLog
LinkService only sees the interfaces.
Versions and tools you'll want
| Tool | Version I used | Check it with |
|---|---|---|
| JDK | OpenJDK 21.0.12.1 (Ubuntu build) | java -version |
| Build | plain javac, no Maven or Gradle |
javac -version |
| IntelliJ IDEA / Eclipse | any current release, only needed if you'd rather click | Help → About |
No third-party libraries at all. I wanted to use JUnit for the tests, but Maven Central wasn't reachable from the machine I built this on, so the tests are a plain main method with a tiny check() helper. Section 6 shows how to convert them to JUnit 5 in about two minutes. Java 17 is enough for everything here, since I compiled the main sources with --release 17 too and they passed.
Get the shortener project onto disk
Create this tree. Every file is listed in full below, so you can type or paste them in.
shortener/
└── src/
├── main/java/dev/ggorantala/shortener/
│ ├── ShortLink.java
│ ├── LinkService.java
│ ├── Main.java
│ ├── codes/
│ │ ├── CodeGenerator.java
│ │ ├── CodeGenerators.java
│ │ ├── Base62.java
│ │ ├── SequentialCodeGenerator.java
│ │ ├── RandomCodeGenerator.java
│ │ └── HashCodeGenerator.java
│ └── storage/
│ ├── LinkStore.java
│ ├── ClickLog.java
│ ├── StorageFactory.java
│ ├── InMemoryStorageFactory.java
│ └── FileStorageFactory.java
└── test/java/dev/ggorantala/shortener/Checks.java
From the command line (what I used):
mkdir -p shortener/src/main/java/dev/ggorantala/shortener/{codes,storage}
mkdir -p shortener/src/test/java/dev/ggorantala/shortener
cd shortener
In IntelliJ IDEA: File → New → Project, choose Java, pick your JDK 21, build system IntelliJ. Then create the packages dev.ggorantala.shortener, dev.ggorantala.shortener.codes and dev.ggorantala.shortener.storage under src. I didn't click through this on the machine I built on (no GUI), so treat the menu path as from the JetBrains docs, not executed.
In Eclipse: File → New → Java Project, set the execution environment to JavaSE-21, then right-click src → New → Package for the three packages. Same caveat: docs, not executed.
Compile and run from the project root on Linux or macOS:
mkdir -p out
javac -Xlint:all -d out $(find src/main -name '*.java')
java -cp out dev.ggorantala.shortener.Main sequential memory
On Windows PowerShell the $(find ...) trick doesn't exist. Use javac -d out (Get-ChildItem -Recurse src/main -Filter *.java).FullName instead. I haven't run that line; it's the standard PowerShell equivalent.
How to implement the factory pattern in Java, three factories deep
Three steps, each one a different kind of factory. Build them in order, because each one fixes a problem the previous one leaves behind.
Step 1: Put validation in a static factory method
Goal: make it impossible to hold an invalid ShortLink, and give construction a readable name.
src/main/java/dev/ggorantala/shortener/ShortLink.java
package dev.ggorantala.shortener;
import java.net.URI;
import java.time.Instant;
/** One shortened link. Constructed only through the static factories. */
public record ShortLink(String code, URI target, Instant createdAt) {
// Compact constructor: every instance, however created, passes through here.
public ShortLink {
if (code == null || !code.matches("[0-9A-Za-z]{1,12}")) {
throw new IllegalArgumentException("code must be 1-12 letters or digits: " + code);
}
if (target == null || target.getScheme() == null
|| !(target.getScheme().equals("http") || target.getScheme().equals("https"))) {
throw new IllegalArgumentException("target must be an http(s) URI: " + target);
}
if (createdAt == null) {
throw new IllegalArgumentException("createdAt is required");
}
}
/** Named factory: says what it does, unlike new ShortLink(...). */
public static ShortLink of(String code, String target, Instant createdAt) {
return new ShortLink(code, URI.create(target), createdAt);
}
/** Factory from a stored line: code, tab, target, tab, epoch millis. */
public static ShortLink parse(String line) {
String[] parts = line.split("\t");
if (parts.length != 3) {
throw new IllegalArgumentException("expected 3 tab-separated fields: " + line);
}
return of(parts[0], parts[1], Instant.ofEpochMilli(Long.parseLong(parts[2])));
}
public String toLine() {
return code + "\t" + target + "\t" + createdAt.toEpochMilli();
}
}
A static factory method is a plain static method that returns an instance: ShortLink.of(...) and ShortLink.parse(...) above. It's the simplest factory there is and the one I reach for first. parse can fail in ways a constructor can't describe, and the name of reads better than new ShortLink(code, URI.create(...), now) at every call site. The real guarantee comes from the record's compact constructor, though: every route in, including plain new, runs the same checks.
Checkpoint: nothing to run yet. Move on.
Step 2: Define what a code generator is, then hide the implementations
Goal: callers depend on one small interface and never name a concrete class.
src/main/java/dev/ggorantala/shortener/codes/CodeGenerator.java
package dev.ggorantala.shortener.codes;
/** Product interface: anything that can hand out the next short code. */
public interface CodeGenerator {
String next(String target);
}
src/main/java/dev/ggorantala/shortener/codes/Base62.java
package dev.ggorantala.shortener.codes;
final class Base62 {
private static final String ALPHABET =
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
private Base62() { }
static String encode(long value) {
if (value == 0) return "0";
StringBuilder sb = new StringBuilder();
long v = value;
while (v > 0) {
sb.append(ALPHABET.charAt((int) (v % 62)));
v /= 62;
}
return sb.reverse().toString();
}
}
Three implementations. All three are package-private (no public), which is the whole point: code outside the codes package physically can't write new HashCodeGenerator().
src/main/java/dev/ggorantala/shortener/codes/SequentialCodeGenerator.java
package dev.ggorantala.shortener.codes;
import java.util.concurrent.atomic.AtomicLong;
/** 1, 2, 3 ... in base 62. Predictable, so fine for tests and internal links. */
final class SequentialCodeGenerator implements CodeGenerator {
private final AtomicLong counter = new AtomicLong(1000);
@Override
public String next(String target) {
return Base62.encode(counter.incrementAndGet());
}
}
src/main/java/dev/ggorantala/shortener/codes/RandomCodeGenerator.java
package dev.ggorantala.shortener.codes;
import java.security.SecureRandom;
/** Seven random base-62 digits. Not guessable, but collisions are possible. */
final class RandomCodeGenerator implements CodeGenerator {
private final SecureRandom random = new SecureRandom();
@Override
public String next(String target) {
return Base62.encode(Math.abs(random.nextLong() % 3_521_614_606_208L)); // 62^7
}
}
src/main/java/dev/ggorantala/shortener/codes/HashCodeGenerator.java
package dev.ggorantala.shortener.codes;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
/** Same target, same code. Handy for de-duplicating links. */
final class HashCodeGenerator implements CodeGenerator {
@Override
public String next(String target) {
try {
byte[] d = MessageDigest.getInstance("SHA-256")
.digest(target.getBytes(StandardCharsets.UTF_8));
long v = 0;
for (int i = 0; i < 6; i++) v = (v << 8) | (d[i] & 0xFF);
return Base62.encode(v);
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 is required on every JDK", e);
}
}
}
Checkpoint: javac -d out $(find src/main -name '*.java') can't succeed yet because LinkService and the storage classes don't exist. Keep going; the first real compile is at Step 5.
Step 3: Add a registry factory that picks a class by name
Goal: turn "hash" into a CodeGenerator without a growing if/else chain, and let other code plug in new kinds.
src/main/java/dev/ggorantala/shortener/codes/CodeGenerators.java
package dev.ggorantala.shortener.codes;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Supplier;
/**
* Factory with a registry. Callers ask for a kind by name and never see the
* concrete classes, which stay package-private.
*/
public final class CodeGenerators {
private static final Map<String, Supplier<CodeGenerator>> REGISTRY = new ConcurrentHashMap<>();
static {
REGISTRY.put("sequential", SequentialCodeGenerator::new);
REGISTRY.put("random", RandomCodeGenerator::new);
REGISTRY.put("hash", HashCodeGenerator::new);
}
private CodeGenerators() { }
public static CodeGenerator create(String kind) {
Supplier<CodeGenerator> supplier = REGISTRY.get(kind);
if (supplier == null) {
throw new IllegalArgumentException(
"unknown code generator '" + kind + "', known: " + new java.util.TreeSet<>(REGISTRY.keySet()));
}
return supplier.get();
}
/** Lets other modules (or tests) plug in a kind without editing this class. */
public static void register(String kind, Supplier<CodeGenerator> supplier) {
if (REGISTRY.putIfAbsent(kind, supplier) != null) {
throw new IllegalStateException("kind already registered: " + kind);
}
}
}
The map holds Suppliers, not instances. That matters: SequentialCodeGenerator keeps a counter, so each call to create("sequential") must return a fresh one, or two services would share ids. I check that in the tests.
Why a registry instead of a switch? A switch on a string is fine for three kinds, and in Step 4 I use one. The registry earns its keep when the list of kinds isn't yours to edit: register("fixed", ...) lets a test or a plugin add a kind from the outside.
Step 4: Add an abstract factory for things that must match
Goal: make sure the link store and the click log always come from the same back end.
The first two factories each build one kind of object. An abstract factory builds a family of related objects. Here the family is two things: where links are stored and where clicks are counted. If links live in a file but clicks live in memory, you restart the app and every click count silently resets to zero. I'll show that bug on purpose in the troubleshooting section.
src/main/java/dev/ggorantala/shortener/storage/LinkStore.java
package dev.ggorantala.shortener.storage;
import dev.ggorantala.shortener.ShortLink;
import java.util.Optional;
public interface LinkStore {
boolean putIfAbsent(ShortLink link);
Optional<ShortLink> find(String code);
int size();
}
src/main/java/dev/ggorantala/shortener/storage/ClickLog.java
package dev.ggorantala.shortener.storage;
public interface ClickLog {
void record(String code);
long clicks(String code);
}
src/main/java/dev/ggorantala/shortener/storage/StorageFactory.java
package dev.ggorantala.shortener.storage;
import java.nio.file.Path;
import java.util.Map;
/**
* Abstract factory: one object that produces a matching family of products.
* A store and a click log from the same factory always agree on where data lives.
*/
public interface StorageFactory {
LinkStore linkStore();
ClickLog clickLog();
/** Picks a family from configuration, e.g. storage=memory or storage=file. */
static StorageFactory fromConfig(Map<String, String> config) {
String kind = config.getOrDefault("storage", "memory");
return switch (kind) {
case "memory" -> new InMemoryStorageFactory();
case "file" -> new FileStorageFactory(Path.of(
config.getOrDefault("storage.dir", "data")));
default -> throw new IllegalArgumentException(
"storage must be memory or file, got: " + kind);
};
}
}
The fromConfig method is a static factory on the interface itself, and it uses a switch expression (see switch expressions and pattern matching) so there's no fall-through to get wrong. Now the two families.
src/main/java/dev/ggorantala/shortener/storage/InMemoryStorageFactory.java
package dev.ggorantala.shortener.storage;
import dev.ggorantala.shortener.ShortLink;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.LongAdder;
public final class InMemoryStorageFactory implements StorageFactory {
// One shared instance per factory, so linkStore() called twice means the same store.
private final LinkStore store = new MemoryLinkStore();
private final ClickLog log = new MemoryClickLog();
@Override public LinkStore linkStore() { return store; }
@Override public ClickLog clickLog() { return log; }
private static final class MemoryLinkStore implements LinkStore {
private final Map<String, ShortLink> links = new ConcurrentHashMap<>();
@Override public boolean putIfAbsent(ShortLink link) {
return links.putIfAbsent(link.code(), link) == null;
}
@Override public Optional<ShortLink> find(String code) {
return Optional.ofNullable(links.get(code));
}
@Override public int size() { return links.size(); }
}
private static final class MemoryClickLog implements ClickLog {
private final Map<String, LongAdder> counts = new ConcurrentHashMap<>();
@Override public void record(String code) {
counts.computeIfAbsent(code, c -> new LongAdder()).increment();
}
@Override public long clicks(String code) {
LongAdder a = counts.get(code);
return a == null ? 0 : a.sum();
}
}
}
src/main/java/dev/ggorantala/shortener/storage/FileStorageFactory.java
package dev.ggorantala.shortener.storage;
import dev.ggorantala.shortener.ShortLink;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.List;
import java.util.Optional;
/** Both products append to files in one directory, so they can't drift apart. */
public final class FileStorageFactory implements StorageFactory {
private final LinkStore store;
private final ClickLog log;
public FileStorageFactory(Path dir) {
try {
Files.createDirectories(dir);
} catch (IOException e) {
throw new UncheckedIOException("cannot create " + dir, e);
}
this.store = new FileLinkStore(dir.resolve("links.tsv"));
this.log = new FileClickLog(dir.resolve("clicks.log"));
}
@Override public LinkStore linkStore() { return store; }
@Override public ClickLog clickLog() { return log; }
private static List<String> lines(Path file) {
try {
return Files.exists(file) ? Files.readAllLines(file) : List.of();
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
private static void append(Path file, String line) {
try {
Files.writeString(file, line + System.lineSeparator(),
StandardOpenOption.CREATE, StandardOpenOption.APPEND);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
private static final class FileLinkStore implements LinkStore {
private final Path file;
FileLinkStore(Path file) { this.file = file; }
@Override public synchronized boolean putIfAbsent(ShortLink link) {
if (find(link.code()).isPresent()) return false;
append(file, link.toLine());
return true;
}
@Override public synchronized Optional<ShortLink> find(String code) {
return lines(file).stream().map(ShortLink::parse)
.filter(l -> l.code().equals(code)).findFirst();
}
@Override public synchronized int size() { return lines(file).size(); }
}
private static final class FileClickLog implements ClickLog {
private final Path file;
FileClickLog(Path file) { this.file = file; }
@Override public synchronized void record(String code) { append(file, code); }
@Override public synchronized long clicks(String code) {
return lines(file).stream().filter(code::equals).count();
}
}
}
Notice that the factory creates each product once, in its constructor, and linkStore() hands back the same instance every time. If it built a new store on each call, linkService.shorten() would write into one map and resolve() would read from another. The file version is deliberately naive: it re-reads the whole file on every lookup. That's fine for a tutorial and wrong for production; see "What to change before real traffic".
(The in-memory pieces use ConcurrentHashMap and LongAdder, which I covered in the concurrency tutorials, so the service is safe to call from several threads.)
Step 5: Write the service that doesn't know any of this
Goal: prove the factories pay off. LinkService should compile against interfaces only.
src/main/java/dev/ggorantala/shortener/LinkService.java
package dev.ggorantala.shortener;
import dev.ggorantala.shortener.codes.CodeGenerator;
import dev.ggorantala.shortener.storage.ClickLog;
import dev.ggorantala.shortener.storage.LinkStore;
import dev.ggorantala.shortener.storage.StorageFactory;
import java.net.URI;
import java.time.Clock;
import java.util.Optional;
/** Depends on interfaces only. It has no idea whether data lives in memory or on disk. */
public final class LinkService {
private final CodeGenerator generator;
private final LinkStore store;
private final ClickLog clicks;
private final Clock clock;
public LinkService(CodeGenerator generator, StorageFactory storage, Clock clock) {
this.generator = generator;
this.store = storage.linkStore();
this.clicks = storage.clickLog();
this.clock = clock;
}
public ShortLink shorten(String target) {
URI.create(target); // fail early on garbage
for (int attempt = 0; attempt < 5; attempt++) {
ShortLink link = ShortLink.of(generator.next(target), target, clock.instant());
if (store.putIfAbsent(link)) return link;
// Same target again with a hash generator: return the existing link.
Optional<ShortLink> existing = store.find(link.code());
if (existing.isPresent() && existing.get().target().equals(link.target())) {
return existing.get();
}
}
throw new IllegalStateException("could not find a free code for " + target);
}
public Optional<URI> resolve(String code) {
Optional<ShortLink> link = store.find(code);
link.ifPresent(l -> clicks.record(code));
return link.map(ShortLink::target);
}
public long clicks(String code) { return clicks.clicks(code); }
}
src/main/java/dev/ggorantala/shortener/Main.java
package dev.ggorantala.shortener;
import dev.ggorantala.shortener.codes.CodeGenerators;
import dev.ggorantala.shortener.storage.StorageFactory;
import java.time.Clock;
import java.util.Map;
public final class Main {
public static void main(String[] args) {
String gen = args.length > 0 ? args[0] : "sequential";
String storage = args.length > 1 ? args[1] : "memory";
Map<String, String> config = Map.of("storage", storage, "storage.dir", "build/data");
LinkService service = new LinkService(
CodeGenerators.create(gen), StorageFactory.fromConfig(config), Clock.systemUTC());
var a = service.shorten("https://ggorantala.dev/how-to-use-java-records/");
var b = service.shorten("https://ggorantala.dev/how-to-use-optional-java/");
System.out.println("generator=" + gen + " storage=" + storage);
System.out.println(a.code() + " -> " + a.target());
System.out.println(b.code() + " -> " + b.target());
service.resolve(a.code());
service.resolve(a.code());
service.resolve(b.code());
System.out.println("clicks " + a.code() + " = " + service.clicks(a.code()));
System.out.println("clicks " + b.code() + " = " + service.clicks(b.code()));
System.out.println("unknown code -> " + service.resolve("nope"));
}
}
Look at the imports in LinkService: CodeGenerator, ClickLog, LinkStore, StorageFactory. Not one concrete class. That's the payoff. If you later add RedisStorageFactory, this file doesn't change.
Checkpoint: compile with warnings on.
javac -Xlint:all -d out $(find src/main -name '*.java')
A clean compile prints nothing from javac. (My shell also printed a Picked up JAVA_TOOL_OPTIONS line, which comes from the environment, not from your code.) It compiled first time for me, which made me suspicious enough to go hunting for ways to break it. Those are in the troubleshooting section.
Swap generators and storage from the command line
Each run below is the real output from my machine.
java -cp out dev.ggorantala.shortener.Main sequential memory
java -cp out dev.ggorantala.shortener.Main hash file
java -cp out dev.ggorantala.shortener.Main random memory
The first two are at the top of this article. The third:
generator=random storage=memory
oVqvLzM -> https://ggorantala.dev/how-to-use-java-records/
cc84tpz -> https://ggorantala.dev/how-to-use-optional-java/
clicks oVqvLzM = 2
clicks cc84tpz = 1
unknown code -> Optional.empty
Your random codes will differ. Now look at what the file family left on disk:
ls build/data
cat build/data/*
clicks.log
links.tsv
1CMqNAHRO
1CMqNAHRO
1DQhMUpnP
1CMqNAHRO https://ggorantala.dev/how-to-use-java-records/ 1793068046980
1DQhMUpnP https://ggorantala.dev/how-to-use-optional-java/ 1793068046996
cat printed clicks.log first (three clicks), then links.tsv (two links with epoch-millisecond timestamps). Run the hash file command again and you'll see shorten return the same two codes without adding lines to links.tsv, because the hash generator maps the same URL to the same code and the service recognises the existing link. Your timestamps will differ from mine.
Check it with 26 assertions
src/test/java/dev/ggorantala/shortener/Checks.java
package dev.ggorantala.shortener;
import dev.ggorantala.shortener.codes.*;
import dev.ggorantala.shortener.storage.*;
import java.nio.file.*;
import java.time.*;
import java.util.*;
public final class Checks {
static int passed, failed;
static void check(String name, boolean ok) {
if (ok) passed++; else failed++;
System.out.println((ok ? "PASS " : "FAIL ") + name);
}
static boolean throwsIae(Runnable r) {
try { r.run(); return false; } catch (IllegalArgumentException e) { return true; }
}
public static void main(String[] args) throws Exception {
Instant t = Instant.parse("2026-10-27T00:00:00Z");
Clock clock = Clock.fixed(t, ZoneOffset.UTC);
check("ShortLink.of builds a valid link", ShortLink.of("abc", "https://a.dev/x", t).code().equals("abc"));
check("bad code rejected", throwsIae(() -> ShortLink.of("a b", "https://a.dev", t)));
check("empty code rejected", throwsIae(() -> ShortLink.of("", "https://a.dev", t)));
check("ftp scheme rejected", throwsIae(() -> ShortLink.of("abc", "ftp://a.dev", t)));
check("parse round-trips toLine", ShortLink.parse(ShortLink.of("abc", "https://a.dev/x", t).toLine())
.equals(ShortLink.of("abc", "https://a.dev/x", t)));
check("parse rejects wrong field count", throwsIae(() -> ShortLink.parse("abc\thttps://a.dev")));
check("sequential is predictable",
CodeGenerators.create("sequential").next("x").equals("G9"));
CodeGenerator seq = CodeGenerators.create("sequential");
seq.next("x");
check("sequential increments", seq.next("x").equals("GA"));
check("each create() gives a fresh generator",
CodeGenerators.create("sequential") != CodeGenerators.create("sequential"));
check("hash is deterministic",
CodeGenerators.create("hash").next("u").equals(CodeGenerators.create("hash").next("u")));
check("hash differs per target",
!CodeGenerators.create("hash").next("u1").equals(CodeGenerators.create("hash").next("u2")));
check("random yields valid codes", ShortLink.of(CodeGenerators.create("random").next("u"),
"https://a.dev", t) != null);
check("unknown kind rejected", throwsIae(() -> CodeGenerators.create("nope")));
CodeGenerators.register("fixed", () -> target -> "ZZ");
check("register plugs in a new kind", CodeGenerators.create("fixed").next("u").equals("ZZ"));
boolean dup = false;
try { CodeGenerators.register("fixed", () -> target -> "YY"); } catch (IllegalStateException e) { dup = true; }
check("duplicate registration rejected", dup);
StorageFactory mem = StorageFactory.fromConfig(Map.of());
check("default storage is memory", mem instanceof InMemoryStorageFactory);
check("linkStore() returns the same instance", mem.linkStore() == mem.linkStore());
check("unknown storage rejected", throwsIae(() -> StorageFactory.fromConfig(Map.of("storage", "s3"))));
LinkService svc = new LinkService(CodeGenerators.create("sequential"), mem, clock);
ShortLink a = svc.shorten("https://a.dev/1");
check("shorten stores and resolve finds", svc.resolve(a.code()).get().toString().equals("https://a.dev/1"));
svc.resolve(a.code());
check("clicks counted", svc.clicks(a.code()) == 2);
check("unknown code resolves empty", svc.resolve("nope").isEmpty());
check("unknown code is not counted", svc.clicks("nope") == 0);
LinkService hashSvc = new LinkService(CodeGenerators.create("hash"), StorageFactory.fromConfig(Map.of()), clock);
check("same target twice returns same link", hashSvc.shorten("https://a.dev/1").equals(hashSvc.shorten("https://a.dev/1")));
Path dir = Files.createTempDirectory("shortener");
StorageFactory file = StorageFactory.fromConfig(Map.of("storage", "file", "storage.dir", dir.toString()));
LinkService f1 = new LinkService(CodeGenerators.create("hash"), file, clock);
ShortLink fl = f1.shorten("https://a.dev/persist");
f1.resolve(fl.code());
LinkService f2 = new LinkService(CodeGenerators.create("hash"),
new FileStorageFactory(dir), clock);
check("file family survives a restart (link)", f2.resolve(fl.code()).isPresent());
check("file family survives a restart (clicks)", f2.clicks(fl.code()) == 2);
check("file store has one line", Files.readAllLines(dir.resolve("links.tsv")).size() == 1);
System.out.println(passed + " passed, " + failed + " failed");
if (failed > 0) System.exit(1);
}
}
Compile and run:
javac -Xlint:all -d out -cp out src/test/java/dev/ggorantala/shortener/Checks.java
java -cp out dev.ggorantala.shortener.Checks
PASS ShortLink.of builds a valid link
PASS bad code rejected
PASS empty code rejected
PASS ftp scheme rejected
PASS parse round-trips toLine
PASS parse rejects wrong field count
PASS sequential is predictable
PASS sequential increments
PASS each create() gives a fresh generator
PASS hash is deterministic
PASS hash differs per target
PASS random yields valid codes
PASS unknown kind rejected
PASS register plugs in a new kind
PASS duplicate registration rejected
PASS default storage is memory
PASS linkStore() returns the same instance
PASS unknown storage rejected
PASS shorten stores and resolve finds
PASS clicks counted
PASS unknown code resolves empty
PASS unknown code is not counted
PASS same target twice returns same link
PASS file family survives a restart (link)
PASS file family survives a restart (clicks)
PASS file store has one line
26 passed, 0 failed
To move these to JUnit 5, add org.junit.jupiter:junit-jupiter as a test dependency (check the current version on Maven Central; I couldn't), turn each check(...) into an @Test with assertThat(...) from AssertJ, and turn throwsIae into assertThrows(IllegalArgumentException.class, ...). The unit-testing tutorial in this series (how to write unit tests with JUnit 5, coming) covers the setup.
The check I care about most is linkStore() returns the same instance. It's the one that catches the "factory builds a new product on every call" mistake.
Why callers never see the concrete classes
Three ideas are doing the work, and they get mixed up a lot.
A static factory method (ShortLink.of) is a named constructor. It doesn't hide a class; it gives construction a name and a place to put checks. Joshua Bloch's Effective Java lists several advantages; the one I find real in daily work is the name.
A factory with a registry (CodeGenerators) hides the implementing classes. Because SequentialCodeGenerator and friends are package-private, the compiler enforces that nobody outside the package can depend on them. You can rename, split or replace them without touching a caller.
An abstract factory (StorageFactory) is for families. The question to ask is: "do these objects only make sense together?" If a LinkStore and a ClickLog can be mixed freely, you don't need one; two separate factories would do. They can't, so one factory builds both.
What I'd skip: a FactoryOfFactories, or a factory for a class that has one implementation and no validation. If the only caller is new Foo() and Foo will never have a sibling, the factory is noise. I'd wait for the second implementation before extracting an interface, then add the factory. The strategy pattern tutorial is the cousin of this one: strategy swaps behaviour at runtime, a factory decides what to build in the first place. They often travel together, as CodeGenerator does here.
Mistakes I made on purpose
I tried to break every promise the article makes. These are the failures, with the exact messages.
Calling new on a hidden implementation. From a class outside the package:
CodeGenerator g = new SequentialCodeGenerator();
T1.java:2: error: cannot find symbol
public class T1 { public static void main(String[] a) { CodeGenerator g = new SequentialCodeGenerator(); } }
^
symbol: class SequentialCodeGenerator
location: class T1
1 error
Cause: the class isn't public, so it doesn't exist as far as other packages are concerned. Fix: call CodeGenerators.create("sequential"). This error is the pattern working, not failing.
A typo in the kind name. I wrote "sequencial":
Exception in thread "main" java.lang.IllegalArgumentException: unknown code generator 'sequencial', known: [hash, random, sequential]
at dev.ggorantala.shortener.codes.CodeGenerators.create(CodeGenerators.java:27)
Cause: strings aren't checked by the compiler. Fix: the message lists the valid kinds, which is exactly why I put them there. If kinds are fixed at compile time, use an enum instead and the typo becomes a compile error.
A storage name in the wrong case. Map.of("storage","File"):
Exception in thread "main" java.lang.IllegalArgumentException: storage must be memory or file, got: File
at dev.ggorantala.shortener.storage.StorageFactory.fromConfig(StorageFactory.java:21)
Fix: lower-case, or call toLowerCase(Locale.ROOT) before the switch if you want to be forgiving. I left it strict so config mistakes fail at startup rather than quietly picking a default.
A link nobody should be able to build. ShortLink.of("ok", "javascript:alert(1)", Instant.now()):
Exception in thread "main" java.lang.IllegalArgumentException: target must be an http(s) URI: javascript:alert(1)
at dev.ggorantala.shortener.ShortLink.<init>(ShortLink.java:16)
at dev.ggorantala.shortener.ShortLink.of(ShortLink.java:25)
The stack shows <init> being called from of, which is the compact constructor doing its job. A shortener that redirects to javascript: URLs is an open door, so this check is not decoration.
Mixing families. This is the bug the abstract factory exists to prevent. I wrote a throwaway StorageFactory that takes the link store from the in-memory family and the click log from the file family, then ran the same service twice, as if the app had restarted:
run 1: code=G9 clicks=1
run 2: code=G9 clicks=2
Look closely. Run 2 shows clicks=2 for a link that, from its own point of view, was created seconds ago and clicked once. The file kept the first run's click; memory forgot the link. No exception, no warning, just numbers that are quietly wrong. That's the failure mode to worry about with mixed families: it doesn't crash, it lies. It's the most useful thing I learned writing this article.
What to change before real traffic
- Don't ship the file back end. It re-reads the whole file for every lookup and takes a
synchronizedlock per store. It exists to show a second family. A real second family would be JDBC or Redis, built by aJdbcStorageFactorywith a connection pool inside it. - Handle
randomcollisions deliberately.LinkService.shortenretries up to five times on a clash. That's enough for a demo and arbitrary for production; pick the retry limit from your id space and traffic. - Don't use
hashwhere URLs are private. It's deterministic, so anyone who guesses a URL can compute its code. - Let a framework build the objects when you have one. In Spring Boot, the container is the factory and profiles (see the upcoming Spring Boot profiles tutorial) pick the family. Hand-written abstract factories are mostly for code that runs outside a container.
- Version note. Nothing here needs Java 21 specifically. Records and switch expressions need 16 or 14+;
--release 17compiled all main sources cleanly. On Java 11 you'd replace the record with a final class.
If you read the earlier tutorials, the builder pattern is the right tool when an object has many optional fields, records and immutable classes cover why ShortLink is shaped the way it is, and sealed classes are the next step if you want the compiler to know every possible generator kind.
Rules to keep
- Start with a static factory method (
of,parse,from) when you need a name or validation. - Hide implementations behind an interface and package-private classes; let a factory be the only place that says
new. - Use a registry (
Map<String, Supplier<T>>) when the list of kinds isn't yours to edit; use anenumorswitchwhen it is. - Use an abstract factory only for products that must match, and make it create each product once.
- Test that each factory call returns what you think: fresh instance or shared one.
- Fail loudly on unknown names, and list the valid ones in the message.
Questions people ask about the factory pattern in Java
What's the difference between the factory pattern and the abstract factory pattern?
A factory builds one kind of object and decides which class to use. An abstract factory builds a group of related objects that belong together, like the store and click log here. If your products don't depend on each other, you don't need the abstract version.
Is a static factory method the same as the factory pattern?
Not quite. A static factory method is a named, static way to create an instance, and it's a close relative that doesn't have to hide any class. The classic factory pattern hides which implementation you get back. In practice both are useful, and Step 1 and Step 3 show each.
When should I use an enum instead of a registry?
Use an enum when every kind is known at compile time and you want typos to be compile errors. Use a registry when other modules need to add kinds. I started with the registry here because I wanted register to exist for the tests.
Do I need a factory if I'm using Spring?
Usually not. Spring's container, @Bean methods and profiles do the job for most cases. Hand-written factories matter when you create objects at runtime based on data, or when you're outside a container.
References
- Java SE 21 API docs:
java.util.function.Supplier,ConcurrentHashMap,MessageDigest - JEP 395: Records and JEP 361: Switch Expressions