Europe/Kyiv
Posts

God Class — The Service That Knows Too Much

April 24, 2026
It starts innocently. You need to send a welcome email after registration, and the userRepo is already there in UserService. So you inject MailService and add one method. Then someone needs to handle subscriptions. The user is involved, so… UserService again. Then resumes. Then analytics. Then billing. Before long, UserService has 12 injected dependencies and 400 lines covering six different domains. Nobody dares touch it. That's a God Class — and your whole team is orbiting around it. The signs are usually hiding in plain sight:
  • The constructor takes 8+ dependencies
  • The class name is vague: UserService, AppService, MainManager
  • Methods span completely different concerns — profile, billing, notifications, analytics
  • Every other service in the app injects this one
  • Any new feature gets added here because "everything's already available"
The last point is the trap. Convenience breeds accumulation, and accumulation breeds a class that's impossible to test, maintain, or reason about. Here's what a God Class looks like in a NestJS project:
// ❌ God Class — UserService knows about everything
@Injectable()
export class UserService {
  constructor(
    private readonly userRepo: UserRepository,
    private readonly jobRepo: JobRepository,
    private readonly resumeRepo: ResumeRepository,
    private readonly fileService: FileService,
    private readonly mailer: MailService,
    private readonly stripe: StripeService,
    private readonly notificationService: NotificationService,
    private readonly analyticsService: AnalyticsService,
    private readonly cacheService: CacheService,
  ) {}

  // User CRUD
  async createUser(dto: CreateUserDto) { ... }
  async updateUser(id: string, dto: UpdateUserDto) { ... }
  async deleteUser(id: string) { ... }

  // Profile
  async uploadAvatar(userId: string, file: Express.Multer.File) { ... }
  async updateProfileVisibility(userId: string, isPublic: boolean) { ... }

  // Jobs (why is this here?)
  async getUserAppliedJobs(userId: string) { ... }
  async getSavedJobs(userId: string) { ... }

  // Resumes (why is this here?)
  async uploadResume(userId: string, file: Express.Multer.File) { ... }
  async deleteResume(userId: string, resumeId: string) { ... }

  // Billing (why is this here?)
  async createSubscription(userId: string, planId: string) { ... }
  async cancelSubscription(userId: string) { ... }

  // Notifications (why is this here?)
  async sendWelcomeEmail(userId: string) { ... }
  async sendPasswordResetEmail(userId: string) { ... }
}
Testing sendWelcomeEmail alone requires mocking Stripe, the file service, three repos, and a cache. That's not a test — that's a setup ceremony. Split by domain. Each service gets exactly the dependencies it actually needs:
// ✅ UserService — only user core logic
@Injectable()
export class UserService {
  constructor(private readonly userRepo: UserRepository) {}

  async createUser(dto: CreateUserDto) { ... }
  async updateUser(id: string, dto: UpdateUserDto) { ... }
  async deleteUser(id: string) { ... }
  async findById(id: string) { ... }
}

// ✅ UserProfileService — avatar, visibility, profile settings
@Injectable()
export class UserProfileService {
  constructor(
    private readonly userRepo: UserRepository,
    private readonly fileService: FileService,
  ) {}

  async uploadAvatar(userId: string, file: Express.Multer.File) { ... }
  async updateProfileVisibility(userId: string, isPublic: boolean) { ... }
}

// ✅ ResumeService — resume lifecycle
@Injectable()
export class ResumeService {
  constructor(
    private readonly resumeRepo: ResumeRepository,
    private readonly fileService: FileService,
  ) {}

  async uploadResume(userId: string, file: Express.Multer.File) { ... }
  async deleteResume(userId: string, resumeId: string) { ... }
}

// ✅ SubscriptionService — billing, plans, Stripe
@Injectable()
export class SubscriptionService {
  constructor(
    private readonly stripe: StripeService,
    private readonly userRepo: UserRepository,
  ) {}

  async createSubscription(userId: string, planId: string) { ... }
  async cancelSubscription(userId: string) { ... }
}
Not every large service is a problem. Size alone isn't the issue — responsibility spread is. A service is fine if:
  • All methods belong to the same domain
  • You can describe what it does in one sentence, without "and"
  • Removing one external dependency doesn't make half the methods pointless
A service is a God Class if:
  • It touches billing, email, file storage, and analytics at the same time
  • Removing Stripe would break unrelated user profile methods
  • Writing a test for one method requires setting up six mocks
God Classes don't appear overnight. They grow through a pattern that's hard to resist under pressure:
// The thought process that creates God Classes:
//
// Sprint 1: UserService handles users. Makes sense.
// Sprint 2: Need to send email on register. userRepo is already here — inject MailService.
// Sprint 3: Resumes are user-related. Add it here.
// Sprint 4: Billing touches the user object. UserService again.
// Sprint 5: 'Why is this so hard to test?'
The fix isn't discipline in the moment — it's having a clear rule before the moment arrives: if a new method doesn't belong to the core domain, it belongs in a new service. Count the constructor dependencies. If a class takes more than 4–5, ask whether all of them are genuinely related. Usually, a cluster of 2–3 dependencies is trying to tell you it wants to be its own service.