Writing•

The 'generic' service circular dependency problem

How to tackle your overburdened ‘notification’ or ‘comment’ services when they start accumulating everyone else’s cruft.

A dithered isometric illustration of a charcoal service box connected to three smaller modules by an orange cable that knots and loops back into the box.

It starts out well-intentioned. You have a feature flag service, an analytics service, a transactional email service, a messaging parser or some other generic domain-agnostic thing. You’ve made it generic because you want to keep it decoupled from everything else: it’s general purpose, and a bunch of barely connected services have the same requirement.

However, you also want type safety, so you start strongly typing everything:

// services/analytics.ts

type AnalyticsEvent = {
  type: 'ORDER_PLACED',
  payload: { orderId: string }
} | {
  type: 'PRODUCT_ADDED',
  payload: { productId: string }
} | {
  type: 'USER_REGISTERED',
  payload: { userId: string }
}

interface AnalyticsClient {
  send: (event: AnalyticsEvent) => Promise<void>
}TypeScript

Or:

// services/email.ts

type EmailPayload = {
  type: 'ORDER_CONFIRMATION'
  payload: { ... }
} | {
  type: 'FORGOTTEN_PASSWORD'
  payload: { ... }
}

interface EmailClient {
  send: (email: EmailPayload) => Promise<void>
}TypeScript

You start small, and then it continues to grow. Should the email service know about all emails (it’s in the name, after all), or should the users service know about user emails and the order service know about order emails? Either feels kind of sensible early on in a project.

You quickly realize that unless you want to duplicate data structures across your generic services and your domain services, you need to share them:

// services/analytics.ts
import { OrderPlacedDetails } from '#orders'
import { ProductDetails } from '#products'
import { UserRegisteredDetails } from '#users'

type AnalyticsEvent = {
  type: 'ORDER_PLACED',
  payload: OrderPlacedDetails
} | {
  type: 'PRODUCT_ADDED',
  payload: ProductDetails
} | {
  type: 'USER_REGISTERED',
  payload: UserRegisteredDetails
}TypeScript

Soon your generic domains have to import the kitchen sink, and end up having knowledge of all domains that utilize them. Even worse, your domain services are almost certainly the ones which call them. You have a circular dependency (language permitting, no-can-do with Go), which is not inherently a problem, but it’s the beginnings of a smell.

Two flavors

Most of this coupling takes one of two forms: type-based or logic-based.

The type-based version can be as mundane as an analytics client that only forwards a payload:

// services/orders.ts
import { trackOrder } from '#analytics'

export type Order = {
  id: string
  total: number
}

export function placeOrder(order: Order) {
  trackOrder('ORDER_PLACED', order)
}TypeScript
// services/analytics.ts
import type { Order } from './orders'

export function trackOrder(event: string, payload: Order) {
  return fetch('/analytics', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ event, payload }),
  })
}TypeScript

The orders module calls analytics, and analytics imports Order back. But analytics never reads id or total: it just serializes the payload. We’ve given it a dependency on orders purely to keep the call typed. import type disappears at runtime, so there’s no runtime cycle, but it still smells.

The logic flavor of coupling is far more terrifying, and looks like this beauty I recently found in a notifications service responsible for sending out emails:

export const handler = eventHandler<NotificationEvent>(async (event, { logger }) => {
  if (event.name === 'TaskReassignmentRequired') {
    await handleTaskReassignment(deps)(event)
  } else if (event.name === 'ProjectAnnouncementPosted') {
    await handleProjectAnnouncement(deps)(event)
  } else if (
    event.name === 'ProjectCommentPosted' ||
    event.name === 'TaskCommentPosted' ||
    event.name === 'TaskAttachmentCommentPosted'
  ) {
    await handleComment(deps)(event)
  } else if (
    event.name === 'ProjectCreated' ||
    (event.name === 'TaskAssigneesAdded' && event.payload.task.type === 'PROJECT_SETUP')
  ) {
    await handleProjectSetup({ ...deps, projectsApiClient })(event)
  } else if (isDynamicMessageEvent(event)) {
    await handleDynamicMessage({ ...deps, projectsApiClient })(event)
  }
  // ...25 branches
})TypeScript

I don’t expect you to read or understand that without any context, but what is ostensibly a domain-agnostic, send-only notifications wrapper is:

  • Determining who should get an email.
  • Fetching data to populate the email, from other services.
  • Sending the email.

Obviously this is far worse, and far harder to unpick.

An email service might know about recipients, subjects and bodies. An order-confirmation renderer will probably need to understand order data. A notifications domain might legitimately own preferences, subscriptions and recipient selection. A monster mashup like the above knows all, and will only grow over time.

Options for decoupling

The solution here really depends on the shape of the problem, but here are a few ways out.

Wrap it

The easiest/best/classic way of handling general-purpose coupling is to wrap the dependency, just like you would any library. Whether it’s an HTTP boundary (i.e. a microservice) or an in-process module boundary, this is usually the first thing to try.

Before:

// services/analytics.ts
import type { SubscriptionPlan } from '#subscriptions/subscriptionPlan.ts'

type Payload = {
  type: 'SubscriptionStarted'; subscriptionId: string; plan: SubscriptionPlan
} | {
  type: 'SubscriptionCancelled'; subscriptionId: string; plan: SubscriptionPlan
} // ...etc

export class AnalyticsClient {
  async track(args: { payload: Payload; userId: string }): Promise<void> {}
}TypeScript

After:

// services/analytics.ts

export type AnalyticsClient = {
  // We actually don't care about the payload types at all here,
  // only that they're structurally compatible with what we need.
  // Could always just use a 'JSON' type here from type-fest or similar.
  track: (args: { payload: Record<string, unknown>; userId: string }) => Promise<void>
}TypeScript
// services/subscriptions/analytics.ts
import type { AnalyticsClient } from '#analytics'
import type { SubscriptionPlan } from './subscriptionPlan.ts'

type Payload = {
  type: 'SubscriptionStarted'; subscriptionId: string; plan: SubscriptionPlan
} | {
  type: 'SubscriptionCancelled'; subscriptionId: string; plan: SubscriptionPlan
}

export class SubscriptionAnalyticsClient {
  #client: AnalyticsClient

  constructor(client: AnalyticsClient) {
    this.#client = client
  }

  track(args: { payload: Payload; userId: string }): Promise<void> {
    // This is the most flexible option, since if you did need conversion or
    // any custom logic here instead of just type-safety, you have a place
    // to do it.
    return this.#client.track(args)
  }
}TypeScript

Or, if you control both sides and they’re structurally compatible:

// services/analytics.ts

export class AnalyticsClient<T extends Record<string, unknown> = Record<string, unknown>> {
  async track(args: { payload: T; userId: string }): Promise<void> {}
}TypeScript
// services/subscriptions/analytics.ts
import type { AnalyticsClient } from '#analytics'
import type { SubscriptionPlan } from './subscriptionPlan.ts'

type Payload = {
  type: 'SubscriptionStarted'; subscriptionId: string; plan: SubscriptionPlan
} | {
  type: 'SubscriptionCancelled'; subscriptionId: string; plan: SubscriptionPlan
}

export type SubscriptionAnalyticsClient = AnalyticsClient<Payload>TypeScript

The service-level version of this is a touch more awkward as consumers probably need to call a different endpoint.

Move the logic home

Let’s look at the gnarly notifications handler from earlier. Actual business logic exists in the generic service, so it’s not just a case of wrapping it in a service-specific handler. Instead we need to shuffle logic around so the relevant service is the one responsible for it.

One way would be to carve up the logic into separate event handlers, rather than sharing one:

// services/tasks/notifications/eventHandler.ts

type NotificationEvent = {
  name: 'TaskCommentPosted',
  payload: { taskId: string; comment: string }
} | {
  name: 'TaskAttachmentCommentPosted',
  payload: { taskId: string; comment: string }
}

export const handler = TaskServiceEventHandler<NotificationEvent>(async (event, { logger, commentClient }) => {
  // Any service-specific logic would go here.
  // Once we're ready we can call the generic domain service.
  await commentClient.sendComment(event.payload)
})TypeScript

We’ve spun up a separate handler, owned by the service which cares. This doesn’t mean it has to reimplement everything; it can happily call the notifications service as a dependency. This is runtime coupling, but it’s the type of runtime coupling that a generic service is designed for.

It’s tempting to broadcast domain events and let the notifications service pick them up. But somebody still has to know what those events mean. If the notifications service is supposed to be a generic sender, teaching it about task comments and project setup is how we got into this mess.

Keep that interpretation in the relevant domain, then pass the sender a message it knows how to deliver. This is similar to the ‘wrap it’ idea, just with more moving parts.

Duplicate(ish) the types

If your dependency problem is only in service of strong types, you can always just accept some duplication.

One scenario where this genuinely can make sense and not feel ‘ick’ is consumers declaring a more specific contract based on the fields they actually care about.

The email service might only be concerned with generic types:

// services/email.ts

type Email = {
  organizationId: string;
  [x: string]: unknown;
}

interface EmailClient {
  get(id: string): Promise<Email>
  send: (email: Email) => Promise<void>
}TypeScript

But consumers may wish to deserialize values to a more specific type:

// services/orders.ts

export const OrderShippedEmail = z.object({
  organizationId: z.string().uuid(),
  orderId: z.string().uuid(),
  customerId: z.string().uuid(),
  // etc...
})

async function getOrderShippedEmail(client: EmailClient, id: string) {
  const result = await client.get(id);
  return OrderShippedEmail.parse(result);
}TypeScript

Instead of pushing all the types up to the email service, let consumers parse and validate the types they care about.

This is generally a good practice anyway to avoid coupling between domains, generic or otherwise, and also makes testing a lot easier since you’re not constantly having to invent elaborate fixtures just to satisfy the type checker.

Cross-service contracts never move in lockstep anyway, since deployments will never happen atomically (even if commits can).

In some ways you can think of this as an extension of the interface segregation principle: only declare what you care about. In Go you see this pretty often, especially since interfaces are implicitly implemented. Modules often only declare a small subset of the methods on types they’re passed.

A lot of the circular dependency issues I see arise from one side actually getting way too much information, more than it cares about, just so the type can be reused. Always take a step back and consider what does this module/service/component really care about?

Register it

If you’re rocking a monolith, then a plugin-style registration stage is another option. This is a good one when the generic module genuinely does need to know all of its consumers, even if it doesn’t reference them in code.

Let’s take an authorization package as an example. An authz module answers one question: can this user perform this action on this resource?

// packages/authz.ts
import type { Project } from '#projects'
import type { Document } from '#documents'

export const can = (user: User, action: string, resource: Project | Document) => {
  switch (resource.type) {
    case 'Project':  return user.orgId === resource.orgId && (action !== 'archive' || !resource.archived)
    case 'Document': return user.roles.includes('DOCUMENT_EDITOR')
    // etc...
  }
}TypeScript

Here we have a ‘god’ authz module which handles all authorization logic in our app.

Instead, we can let the authorization module be extended:

// packages/authz.ts
type Policy = (user: User, action: string, resource: any) => boolean

export const createAuthz = () => {
  // Entity to policy.
  const policies = new Map<string, Policy>()

  return {
    register: (type: string, policy: Policy) => policies.set(type, policy),
    can: (user: User, action: string, resource: { type: string }) =>
      policies.get(resource.type)?.(user, action, resource) ?? false,
  }
}

export type Authz = ReturnType<typeof createAuthz>TypeScript
// projects/policy.ts
import type { Authz } from '#authz'
import type { Project } from './project.ts'

export const registerProjectPolicy = (authz: Authz) =>
  authz.register('Project', (user, action, project: Project) => {
    if (user.orgId !== project.orgId) return false
    return action === 'archive' ? !project.archived : true
  })TypeScript

Now the authorization module has no knowledge of any domain entities or policies. Instead they live alongside the domain they protect, which stops a cycle and also is a win for colocation/discovery.

This kind of plugin-style architecture is used a lot in NestJS, for instance in the CQRS module (note that their naming makes this sound like something it isn’t, but let’s ignore that). Each specific module lives in its own domain, but on startup it ‘registers’ itself. Unlike a literal register() method, most of these modules work by walking the DI tree and, using reflection, extracting decorator metadata from the associated classes to build up the registration code. Probably not one to emulate, but essentially the same concept: let the root wire things together and keep the dependency tree acyclic through registration.

This means that things which genuinely need to be global, like HTTP route handlers, event listeners etc. can be defined locally and then registered with the parent.

What about types, though? When we register a policy in the projects module, how do we ensure we get strong typing? If you’re using Typescript you can use module augmentation. The generic module can expose an interface which other modules in turn extend (as long as they’re part of the import graph):

// packages/authz.ts

// Deliberately empty: domains fill it via `declare module`.
// Must be an interface, not a type, for declaration merging.
export interface AuthzResources {}

export type Authz = {
  register: <K extends keyof AuthzResources & string>(
    type: K,
    policy: (
      user: User,
      action: AuthzResources[K]['action'],
      resource: AuthzResources[K]['resource'],
    ) => boolean,
  ) => void
  // can() follows the same shape.
}TypeScript
// projects/policy.ts
import type { Authz } from '#authz'
import type { Project } from './project.ts'

declare module '#authz' {
  interface AuthzResources {
    Project: { resource: Project; action: 'view' | 'archive' | 'edit' }
  }
}

export const registerProjectPolicy = (authz: Authz) =>
  authz.register('Project', (user, action, project) => { /* still inferred */ })TypeScript

Module augmentation would also work for our type-only analytics client example.

It isn’t always possible or practical, though. If you can’t (or don’t want to) merge a global interface, use a plain generic: let Authz take the resource map as a type parameter, and have each domain define its own map and declare Authz<ProjectResources> wherever it uses it.

Just imagine everything is a jQuery plugin, if you still remember those days.

The shared kernel

If we’re just talking DTOs, I know what you’re thinking, you’re smarter than this, you can extract a shared kernel and share that:

// @app/event-catalog
export type {
  OrderPlaced, OrderCancelled, SubscriptionStarted, SubscriptionCancelled,
  InvoicePaid, PaymentFailed, ProductAdded, ShipmentDispatched,
  UserRegistered, UserRoleChanged,
  // ...~200 more
}TypeScript

Now both sides can import it, e.g. the analytics service and the subscriptions service, no touching. But all you’ve done is cheat ESLint/Oxlint, since:

  • The event lives nowhere near the thing it’s related to.
  • You have created a junk drawer more egregious than src/helpers.
  • You are just as conceptually coupled, even if you don’t have a circular dependency.

Depending on how you type-check and transpile, this may have a performance benefit anyway. The analytics service doesn’t need to import the entire subscriptions service (or the entire subscription service’s .d.ts, again it depends). But this doesn’t address the problem we’re actually trying to solve.

Accept it if it only goes one way

If you have some of the symptoms described here, but don’t actually have a circular dependency, then you probably just don’t like the way you’ve carved up your domains. It also means it’s probably going to be hard to unpick, with diminishing returns.

Here’s part of a type payload from a comments service I ran into recently:

// services/comments/types.ts

const CommentEntityTypeSchema = z.union([
  z.literal('project'),
  z.literal('task'),
  z.literal('document'),
  z.literal('taskAttachment'),
])TypeScript

Immediately it’s obvious that the comments service is just going to grow, and has to maintain knowledge of everything which needs a comment. You can probably think of several different, more generic designs.

But as long as the domain services don’t read from the comments service (i.e. nobody imports its types back, so the arrows only point one way), do we actually have a problem? Not necessarily: you’ve just decided that the comments service isn’t truly generic. Think instead about coordination overhead and rate of change: does making a new ‘thing’ commentable require a code change, a deployment, and cooperation between services?

If not, maybe it’s not worth the effort to rethink.

Conclusion

So, should the email service know about all emails? No. The email service knows how to send an email: recipients, subjects, bodies. The users service knows what a welcome email says, and the orders service knows what goes in an order confirmation. Ideally the arrows only point one way.

Protect your ‘generic’ services. Don’t let any business logic which isn’t in the language of their own bounded context slip in: notifications knows about emails, not projects.

Or decide the service was never really generic in the first place, and let it own its domain outright. If you open the floodgates, broken windows theory will take over and you’ll end up with a tangled mess of cruft.