Skip to content
aipatterns.dev

Refactoring techniqueMoving Features between Objects

Extract Class

Split a class that does two jobs by moving one job's fields and methods into a new class it holds a reference to.

Problem

One class has grown two responsibilities. A subset of its fields and methods change together and are used together, while the rest of the class ignores them. The class is long and changes for unrelated reasons.

Solution

Create a new class for the cohesive subset, give the original a field that holds an instance, and move the fields and methods over one at a time.

Before

TypeScript
class User {
  constructor(
    public id: string,
    public email: string,
    public displayName: string,
    private passwordHash: string,
    private failedLogins: number,
    private lockedUntil: Date | null,
  ) {}

  rename(name: string) { this.displayName = name; }

  recordFailedLogin(now: Date): void {
    this.failedLogins += 1;
    if (this.failedLogins >= 5) this.lockedUntil = addMinutes(now, 15);
  }

  isLocked(now: Date): boolean {
    return this.lockedUntil !== null && now < this.lockedUntil;
  }

  resetLoginAttempts(): void {
    this.failedLogins = 0;
    this.lockedUntil = null;
  }
}

After

TypeScript
class User {
  constructor(
    public id: string,
    public email: string,
    public displayName: string,
    private passwordHash: string,
    readonly lockout: LoginLockout,
  ) {}

  rename(name: string) { this.displayName = name; }
}

class LoginLockout {
  constructor(private failedLogins = 0, private lockedUntil: Date | null = null) {}

  recordFailure(now: Date): void {
    this.failedLogins += 1;
    if (this.failedLogins >= 5) this.lockedUntil = addMinutes(now, 15);
  }

  isLocked(now: Date): boolean {
    return this.lockedUntil !== null && now < this.lockedUntil;
  }

  reset(): void {
    this.failedLogins = 0;
    this.lockedUntil = null;
  }
}

Why

  • Lockout policy changes (thresholds, backoff) no longer touch the profile class.
  • LoginLockout is testable without building a full user.
  • The same lockout object can later protect API keys or one-time codes.

Steps

  1. Name the responsibility you are splitting off. If the name is vague ("UserHelper"), reconsider the split.
  2. Create the new class, empty. Add a field in the original that holds an instance, created in the constructor.
  3. Move one field with Move Field. Run the tests.
  4. Move methods with Move Method, starting with those that only use moved fields. Run the tests after each.
  5. Review both interfaces. Rename methods that now read oddly (recordFailedLogin became recordFailure).
  6. Decide whether to expose the new object (user.lockout) or keep delegating methods on the original.

Watch out

  • Don't extract when the subset is two fields and one method with no prospect of growth. The new class adds indirection without hiding anything.
  • Aliasing: if you expose the new object, callers can mutate it directly. Expose it read-only, or keep delegating methods, if the original must control changes.
  • Persistence often stores the object as one row or document. Map the extracted class as an embedded value rather than a new table unless you need it.
  • The inverse is Inline Class.

Agent checklist

  • The extracted class has a clear, specific name and a single reason to change.
  • Fields moved first, then methods, with tests green after each move.
  • The original class's public API is either preserved via delegation or callers were updated.
  • Mutability of the exposed new object was decided deliberately.
  • Persistence and serialization still produce the same shape, or a migration exists.

Treats these smells

See also

Original writing and examples. Topic coverage follows Refactoring.Guru, which explains this topic with its own illustrations.