Avoiding circular refs
in NestJS
@Global()
@Global()
@Global()
@Module({
imports: [...],
controllers: [...],
providers: [...],
exports: [...],
})
export class SomeModule {}@Global()
- You don't need to import global module into feature modules
- You need to import global module once into AppModule
@Global()
- No circular refs between modules
- No way to provide overrides for different feature modules
(I don't think we use it) - Still can have circular refs between services
- NestJS says it's bad practice
← I don't care
@Global()
Module per service
Module per service
Crazy idea, huh?
Module per service
Nope
Module per service
@AtomicService({
imports: [AppConfigModule],
})
export class SurveyScheduleAutoremindersFn {
constructor(
private readonly appConfig: AppConfigService,
private readonly someOtherFn: SomeOtherFn
) {}
...
}automated sugar
Module per service
@Module({
imports: [AppConfigModule, SomeOtherFn],
providers: [SurveyScheduleAutoremindersFn],
exports: [SurveyScheduleAutoremindersFn]
})
@Injectable()
export class SurveyScheduleAutoremindersFn {
constructor(
private readonly appConfig: AppConfigService,
private readonly someOtherFn: SomeOtherFn
) {}
...
}which will be convered into smth like this
(that's a lie, it's a bit more complex than that, but PoC works)
Module per service
@Module({
imports: [AppConfigModule, SomeOtherFn],
providers: [SurveyScheduleAutoremindersFn],
exports: [SurveyScheduleAutoremindersFn]
})
@Injectable()
export class SurveyScheduleAutoremindersFn {
constructor(
private readonly appConfig: AppConfigService,
private readonly someOtherFn: SomeOtherFn
) {}
...
}auto-imports
Module per service
@Module({
providers: [
...,
// uses SurveyScheduleAutoremindersFn
SurveysService,
],
imports: [
...,
// thus we need to import module
getModule(SurveyScheduleAutoremindersFn)
],
exports: [...]
})
export class SurveysModuleinterop with regular modules (ugly)
Module per service
@SpanModule({
providers: [
...,
// uses SurveyScheduleAutoremindersFn
SurveysService,
],
imports: [
...,
// we don't need to import AtomicModule
// cuz @SpanModule does that for us
],
exports: [...]
})
export class SurveysModuleinterop with regular modules (nice)
Module per service
what's left in regular modules?
- controller definitions
- bullmq-related definitions
- services with cron
Module per service
Why?
Module per service
@Global()
@Module({
imports: [
FirstControllerModule,
SecondControllerModule,
// ugly imports
FirstServicesModule,
SecondServicesModule,
ThirdServicesModule,
]
})
export class AppModule {}Module per service
Atomic Services
@Module({
imports: [
FirstControllerModule,
SecondControllerModule,
// no ugly imports
// at all
// forever
// cuz it's automated
]
})
export class AppModule {}easier to add services → more services → granular services
Module per service
Circular refs in services
Circular refs in services
The vast majority (70%? 80%? 90%?)
of code is not modeled in OOD paradigm
(or any paradigm at all)
(and the 25% of OOD code is modeled in wrong way)
Metrics Engine is interesting example, while we can say it's proper OOD-modeling, it doesn't use NestJS DI
Circular refs in services
We wanna some new code
We write new function
We push that function into service class
Circular refs in services
"Oh, new function doing smth with people, that should be a PeopleService"
— my mental process
there is no model
behind this process
Circular refs in services
WHYYYYYYYY
Because it's too hard to write a separate service with a single function
Because you need to import new service not only into callee but also provide it in feature module (or app module)
and because we don't have time to invest into proper design - we are a startup after all
Circular refs in services
Let's fix this
trying to achieve functions with DI
and without too much boilerplate
not trying to add any new model
we won't adopt that easily
thus why bother
Circular refs in services
Let's fix this
Oh wait, Atomic Services already almost fix that!
Circular refs in services
Let's fix this
@AtomicService({
imports: [
AppConfigModule
]
})
class SomeFn {
constructor(
private readonly appConfig: AppConfigService
) {}
async handle(...) {
...
}
}Circular refs in services
Let's fix this
@AtomicService()
class SomeFn {
constructor(
private readonly appConfig: AppConfigService
) {}
async handle(...) {
...
}
}AppConfigModule (and some other basic modules) are @Global() now
No need to import it
Circular refs in services
Let's fix this
@AtomicService()
class SomeFn {
constructor(
private readonly appConfig: AppConfigService
) {}
async handle(...) {
...
}
}AppConfigModule (and some other basic modules) are @Global() now
No need to import it
For sure:
- AppConfigModule
- DbModule
- FlagsModule
- RedisModule
Circular refs in services
Let's fix this
@AtomicService()
class SomeFn {
constructor(
private readonly appConfig: AppConfigService
) {}
async handle(...) {
...
}
}AppConfigModule (and some other basic modules) are @Global() now
No need to import it
Maybe:
- PeopleModule
- GroupsModule
- AssetsModule
- MetricsModule
- IntegrationsModule ??
For sure:
- AppConfigModule
- DbModule
- FlagsModule
- RedisModule
Circular refs in services
Fn per service pattern
@AtomicFn()
class SomeFn {
constructor(
private readonly appConfig: AppConfigService
) {}
async handle(...) {
...
}
private someHelper(...) {
}
}← Simple directive, easy to use
← DI always DI
↑ Small amount of deps - cuz fns are small
← single public method
(convention, maybe eslint rule?)
← helpers are private
Circular refs in services
Fn per service pattern
It's kinda hard to introduce new services because you need to manually name files:
SurveysService -> surveys.service.ts.
Let's just name it as SurveysService.ts
One more trouble
VS Code generates name SurveysService.ts
when using "Move to a new file"
Circular refs in services
Fn per service pattern
When?
Old code:
- Almost never cuz we have a bridge between worlds
- But might refactor some existing circular refs (or just use @Global)
Circular refs in services
Fn per service pattern
When?
New code:
- ~Always in new code if there is no significant reason (like actual OOD)
still can be a little pain in some cases
→
Old code:
- Almost never cuz we have a bridge between worlds
- But might refactor some existing circular refs (or just use @Global)
Circular refs in services
Fn per service pattern
When?
New code:
- ~Always in new code if there is no significant reason (like actual OOD)
- When there is circular ref or there are too much lines in file / methods in file
still can be a little pain in some cases
→
→
need to have some new eslint rule or smth
Old code:
- Almost never cuz we have a bridge between worlds
- But might refactor some existing circular refs (or just use @Global)
I really prefer this
→
Circular refs in services
Fn per service pattern
Why?
- people.service.ts - 921 lines / 17 methods
- members.service.ts - 1100 lines / 19 methods
- integrations.service.ts - 1038 lines / 26 methods
- surveys.service.ts - 1182 lines / 15 methods
- (and circular refs ofc)
How soon we will reach 5k LoC per file?
it just becomes hard to navigate
my childhood trauma was being locked in project with 10k LoC files
Circular refs in services
Fn per service pattern
Why not nestjs/cqrs?
- manual imports everywhere (much more of boilerplate)
- a lot of other boilerplate (like repetition of explicit types)
- (special hate for rxjs sagas)
- universal bus that may fail in runtime
Circular refs in services
Fn per service pattern
Why not use Query/Command separation?
I like the approach if that's just a naming convention.
Not sure if we are ready to embed anything more powerful
(I have experience building that, I can build smth like that, but that just would take a time)
Circular refs in services
Fn per service pattern
Pros
- Static
- Simple
- Can be pain in controllers
Cons
→
we can introduce some AtomicApiService
that can be imported only from controllers
so we can have strict separation guarantees
(not sure about this idea)
Circular refs
By Viktor Lova
Circular refs
- 105