levelmax World 5 Explicit Design
  1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. B
L
Level 5.5 ~12 min Target: PHPStan max

Small interfaces

UserRepository has ten methods. PasswordReset uses two of them. To test it with a fake (4.3), you write a class with ten methods: two that work, and eight that throw new LogicException('not needed').

Those eight are the interface telling you it's the wrong size. And they're not free: when someone adds an eleventh method to UserRepository, every implementation and every fake has to grow one, including the ones that will never call it.

Interfaces belong to the client

It's natural to write an interface from the implementation's side: here's everything the database table can do. But an interface is a promise to the code that uses it, and each user needs a different slice.

Client Needs
PasswordReset findByEmail(), save()
GdprErasure findByEmail(), anonymise()
The admin search page search(), countActive()

Give a client an interface sized for its job, and name it for that role. This is the interface segregation principle: no client should depend on methods it doesn't use.

interface UserAccounts
{
    public function findByEmail(string $email): ?User;

    public function save(User $user): void;
}

PasswordReset asks for UserAccounts. Its fake has two methods, its signature says exactly what it does with users, and nobody changing search() ever has to look at it.

Splitting without breaking anything

You don't need to touch the implementations. Make the big interface extend the small one:

interface UserRepository extends UserAccounts
{
    // the other eight methods
}

Every class that implements UserRepository now implements UserAccounts too, so the composition root can still pass a DatabaseUserRepository to PasswordReset. Do the same for the next client when it needs a fake, one role at a time.

Power-up

Before
<?php

// Ten methods, to test a class that calls two
final class FakeUsers implements UserRepository
{
    public function findByEmail(string $email): ?User { /* … */ }
    public function save(User $user): void { /* … */ }

    public function find(int $id): ?User { throw new LogicException('not needed'); }
    public function all(): array { throw new LogicException('not needed'); }
    public function search(string $query, int $limit): array { throw new LogicException('not needed'); }
    // …and five more
}
After
<?php

interface UserAccounts
{
    public function findByEmail(string $email): ?User;

    public function save(User $user): void;
}

interface UserRepository extends UserAccounts { /* the other eight */ }

final readonly class PasswordReset
{
    public function __construct(private UserAccounts $users) {} // a two-method fake will do
}

Quick check

1. A fake for your test has eight methods that only throw new LogicException('not needed'). What does that tell you?
2. How do you split UserRepository without changing DatabaseUserRepository?
3. Who should an interface be designed for?

CHALLENGE

PasswordReset uses two of UserRepository's ten methods, but to test it you'd have to write a fake with all ten. Any change to the big interface also touches it, and every fake of it.

Declare interface UserAccounts with just the two methods PasswordReset uses, and make PasswordReset ask for that. The real DatabaseUserRepository and GdprErasure (both read only) should carry on working without a change.

REFERENCE
interface UserRepository extends UserAccounts
{
    // UserRepository now includes UserAccounts' methods, so everything
    // that implements it implements UserAccounts too.
}
Running on PHP 8.3…

Run the tests, run PHPStan, or submit for both. Ctrl/⌘ + Enter submits from the editor.