Your Shared Library Is Wearing a Trench Coat
How I rebuilt one shared package three times and learned that abstraction has to earn its keep.
TL;DR
I pulled some duplicated RabbitMQ plumbing out of five services into a shared package, then rebuilt that package three times before it was finally honest about what it was. Four things stuck with me. An abstraction is only worth it when it hides a seam that actually varies. Vague names like “core” are a standing invitation to dump more in, so call things what they are. Don’t drape plain infrastructure in DDD ceremony. And keep everything cheap to delete, because you are not going to get the shape right on the first try.
It started with a copy-paste I couldn’t unsee
Five microservices, and every single one of them was carrying the same thousand lines of RabbitMQ plumbing. A connection manager. Topology assertion. A CloudEvents codec. An abstract consumer with retry-to-dead-letter. The files had drifted apart only in their whitespace. Five copies meant five places to fix the next bug, so I did the obvious thing and started pulling it all into one shared package.
I assumed the hard part would be the extraction. It wasn’t. The hard part turned out to be the name, and I didn’t figure that out until I had built the package three separate times.
The one seam that actually mattered
The duplicated code was identical enough that moving it was mostly mechanical. Almost all of it was invariant. The codec, the retry ladder, the consumer lifecycle: none of it cared which service it ran inside.
Except for one thing. Each service read two values off its own config, a broker URL and a prefetch count, and a shared library has no business reaching into a service’s local config to get them. That was the seam. It was the only thing that genuinely varied.
So the package stopped reading config and started receiving it:
RabbitMqModule.forRootAsync({
inject: [EnvService],
useFactory: (env) => ({
rabbitmqUrl: env.get("RABBITMQ_URL"),
prefetch: env.get("RABBITMQ_PREFETCH"),
}),
});
That is the whole game in miniature: find the seam that varies, and abstract exactly that. I managed to get it wrong in both directions before I was done. Early on I abstracted too little. Later I abstracted too much. They feel like opposite mistakes, but they turned out to be the same one seen from different sides.
Two detours that taught me something
The first thing that bit me was types. The package’s public surface exposed a type from amqplib, the ConsumeMessage that every consumer’s handle() receives. The moment a service deleted its own amqplib dependency, its compiler lost the ability to resolve that type, and the build broke in a way that had nothing to do with any code I had written.
The fix was to make the package own its own surface: re-export the transport types it exposes, and ship the type definitions as a real dependency rather than a dev-time convenience. The lesson stuck with me. A library’s types are part of its public contract. Leak a dependency’s types and you have quietly turned that dependency into a peer dependency, whether you meant to or not.
The second detour was more interesting. Four of the five services published events the simple way. The fifth validated every message against a JSON-Schema registry before it went out. My first instinct was to leave that service alone and call it special, which is precisely how you end up maintaining two publishers until the end of time.
Instead I added a single optional hook:
// in the publisher, just before encoding
this.options.validator?.validate(event);
No validator configured, no behavior. The strict service hands in its registry, and the other four never notice it exists. That hook earned its place, because it was hiding something that genuinely differed between services behind a thin, optional seam. Hold onto that feeling, because it is the entire moral of the story.
The question that turned it all around
Then a request landed that looked completely harmless. We need Redis in the package, for caching and some shared state.
So I built it. A managed client, an opt-in module, a logger, the whole works. And then someone asked the question I should have been asking myself:
Is the events package really the right home for a cache client?
It was not, and the answer was obvious the second it was said out loud. But it took saying it. The package was called events-core, and that little word core had quietly handed it permission to absorb anything that smelled vaguely like infrastructure. A cache client has nothing to do with events. Bundling it meant four services that only ever spoke RabbitMQ were now hauling around a Redis driver they would never call.
And then the real problem came into focus. The name had been lying the entire time. There was never a coherent “core” in there at all. It was RabbitMQ and a CloudEvents codec standing on each other’s shoulders inside one long coat, doing their best impression of a single package.
So we split it in two:
events-core -> @scope/rabbitmq (broker, codec, consumer base)
+ @scope/redis (a managed client, and nothing else)
The moment the two packages were named for what they actually were, every open question answered itself. Which service needs which dependency. Where the Redis client belongs. What each package is allowed to grow into. The honest names were not a description of the design. They were the design.
What I took away
An abstraction has to hide a seam that actually moves. Every abstraction that survived the rebuilds was hiding real variation: the injected config, the opt-in logger you want quiet in tests and loud in production, the validator hook, a retry ladder you can tune. Every one I deleted was hiding nothing at all. The “core” framing implied a depth that was not there, and the Redis client had been glued onto an events package for no better reason than both being “infrastructure.” When nothing moves behind the curtain, the curtain is just a thing standing between you and the truth. Now, before I wrap anything, I ask a single question: what seam does this hide, and does it actually move? If the answer is “none,” it is not abstraction. It is indirection wearing abstraction’s clothes.
Call things what they are. Names like core, common, shared, base, and kernel are an abstraction smell. They are vague on purpose, and that vagueness is a standing invitation to dump more in. A package called @scope/rabbitmq cannot quietly grow a Redis client, because the name starts to itch the instant it stops fitting. Honest names put friction in exactly the right place.
Do not bring DDD to your infrastructure. This was the biggest hole I dug for myself, and not only in this one package. I kept reaching for ports, adapters, aggregates, and hexagonal layers on things that were already just plain shared infrastructure. Those patterns earn their cost at a domain boundary, where business rules vary, need protecting, and might one day be swapped out. A Redis client, an AMQP connection, and a schema registry are none of those things. They have one obvious implementation and no rules to defend. Wrapping them in a port-and-adapter sandwich is paying a domain-sized tax to solve an infrastructure-sized problem. The honest shape for infrastructure is a thin wrapper that says what it does. getClient(). publish(). That is the entire interface.
Iteration is not the enemy. Early lock-in is. I built this package three times, and none of it felt like waste. Building it, living with it, and then cutting it back is usually how you find the real shape, because you almost never see it from the start. What made that affordable was keeping everything easy to throw away: small packages, thin wrappers, no kernels invented before they were needed. The pain only arrives when an abstraction gets poured in early and then sets like concrete. Cheap to reverse beats right the first time, because you are not going to be right the first time.
Where it landed
Where it ended up is a lot quieter than where it began. Two small packages that say what they do. Config handed in at the one place it varies. Logging you can switch on. Validation that plugs in for the single service that asked for it. A cache client that is only ever a cache client.
It is less clever than my first version. That is the best thing about it.