Note: We aimed to keep the code snippets in this handbook short and slim. Therefore, they may not contain every detail needed to be compiled and run. Nevertheless, we believe that they are easier to read and comprehend this way.
- What is Chalupa?
- My First Service
- Chalupa Packages
- Configuration
- Logging
- Plugins
- Dependency Injection
- Modules
- Service Communication
- Communication Strategies
- Service Lifecycle
- Error Handling
- Testing
- Extending Chalupa
Chalupa was written to help developers write better software faster.
Like the idea of "better software faster"? Check out Dave Farley's (the co-author of the classic Continuous Delivery book) YouTube channel where he talks about various software engineering topics: Continuous Delivery on YouTube.
Concretely, Chalupa is a thin layer of abstraction over a communication machinery with dependency injection, configuration handling and external service management baked in. There is more to this than meets the eye, however, since the greatest benefit of even such a thin layer is a clear guidance regarding the organization of services, enabling:
- the separation of concerns,
- modularity and reusability,
- test-first design.
Thus, how your services work stays the same, but the way you write them is going to be much more fun.
Here we only show a barebones service for the sake of introduction. If you want to generate a service repository with all the bells and whistles, then use
npm init @chalupajs/service
ornpx @chalupajs/create-service
.
Without further ado, let's jump straight into some code, shall we?
import { Chalupa, InMemoryStrategy } from '@chalupajs/service'
import { Service, ServiceMethod } from '@chalupajs/interface'
/* 1. */
@Service()
class PizzaService {
/* 2. */
@ServiceMethod()
async hello(): string {
return 'Hello, world!'
}
}
async function start() {
/* 3. */
const service = await Chalupa
.builder()
.createServiceWithStrategy(PizzaService, InMemoryStrategy)
/* 4. */
await service.start()
}
start().catch(console.error)
Above, we have a fully functional Chalupa service, which, while doing nothing useful, when started, will publish itself to an in-memory event bus.
- The entrypoint of the service is the
PizzaService
class, decorated with the@Service
decorator. - Services can expose methods to other services using the
ServiceMethod
decorator. In our case, other services can call thehello
method onPizzaService
. - In the
start
function, we create a new Chalupa builder using theChalupa.builder()
call. Then, we pass our service class to thecreateServiceWithStrategy
function, along with theInMemoryStrategy
. These two then produce an executable service from our class, that is able to publish itself to the in-memory event buss. - Finally, calling
start
on the produced service actually fires up event handling.
Chalupa makes heavy use of TypeScript decorators. You can use these resources to learn more about them:
So, now we know how to create a service, but what is a service exactly? It is an independent component, communicating
with other services via a selected communication strategy, in the above case, the
built-in In-Memory Strategy. The interface of a service (that is made available to other
services) is then comprised by its ServiceMethods
and ServiceEvents
(
see Service Communication).
In our current case, the name of the published service is going to be the name of the class, PizzaService
. This can be
overridden by setting the name
option of the @Service
decorator, as follows.
@Service({
name: 'Pizza'
})
class PizzaService {
}
Chalupa is developed in a monorepo (https://github.com/chalupajs/chalupa) and consists of several smaller packages. What are these and when should you use them?
@chalupajs/interface
- The core decorators and types used by Chalupa. Writing a module, a plugin or something along those lines? This package is a must then for you.
@chalupajs/service
- As its name suggests,
interface
is mostly the public interface of Chalupa. The actual implementation, the classes that make ue of the decorators and such reside in theservice
module. Thus, if you want to create an executable service, then use this package.
- As its name suggests,
@chalupajs/test-framework
- Integration test support for Chalupa (see Testing). If you want to test your modules or services, then this is your package.
@chalupajs/logger-pino
- pino logging backend. See Switching the Provider on how to make use of the non-default logging backend.
@chalupajs/logger-tslog
- tslog logging backend. See Switching the Provider on how to make use of the non-default logging backend.
@chalupajs/communication-darcon
- Darcon-based communication layer. Use this package if you want your services to talk to each other via Darcon.
@chalupajs/db-mongo
- Module for connecting to MongoDB via node-mongodb-native.
In most cases, our service has some configurable knobs and toggles to alter its behavior. The widely adopted solution to configure these properties is to load their values from environment variables or configuration files. Chalupa supports this by using konvenient as its configuration solution. However, you don't have to install and import konvenient directly, as Chalupa re-exports the declarations of konvenient for your convenience (haha, got 'em!).
In what follows, we will only scratch the surface of konvenient's capabilities, hence, make sure to check out its documentation for more involved examples and recipes.
Now, let us assume, that we want to save some files into a configurable directory. We can expose this setting via a configuration class as follows.
import { Configuration, Configurable } from '@chalupajs/interface'
@Configuration()
class PizzaConfig {
@Configurable({
doc: 'The data directory to save files into.',
format: String,
})
dataDirectory = '/data/pizza'
}
As you can see, this is also done via decorators, in this case, @Configuration
, which is used to mark a class a
configuration class, and @Configurable
which marks configurable properties.
The dataDirectory
property has a short documentation string, a format (set to string
) which is used for validation
and a default value if nothing is provided. How to provide a value, then? konvenient automatically computes an
environment variable name for configurable properties. The env var for dataDirectory
, in this case,
is PIZZA_DATA_DIRECTORY
. The PIZZA_
prefix comes from the name of the configuration class, preceding the Config
suffix. If we had another property, timeToBakePizza
, then its env var would be PIZZA_TIME_TO_BAKE_PIZZA
.
Using configuration values is now a breeze.
@Service({
inject: [PizzaConfig]
})
class PizzaService {
private readonly config: PizzaConfig
constructor(@Inject(PizzaConfig) config: PizzaConfig) {
this.config = config
console.log(this.config.dataDirectory)
}
}
By listing PizzaConfig
in the inject
array (see Dependency Injection), Chalupa knows that
it has to manage a configuration class.
Here we showed how to use configuration values inside so-called injectable classes (such as the one decorated with
@Service
). If you want to configure how dependency injection works using configuration values, please refer to the Configuration-dependent Binding section.
In what follows, you'll see that several parts of Chalupa can be configured via environment variables, and even
so-called Modules can expose configurable values as well. If multiple services were deployed on the same
instance and they used configurable properties of the same name (for example, level
inside tthe LogConfiguration
class), we were unable to individually configure these values because of the colliding environment variable names (since
each one of them would use LOG_LEVEL
). To remedy such situations, you should always set an environment variable prefix
for your service as follows.
import { EnvPrefix } from '@chalupajs/service'
async function start() {
const service = await Chalupa
.builder()
.use(EnvPrefix.from('PEPPERONI'))
.createServiceWithStrategy(PizzaService, InMemoryStrategy)
await service.start()
}
start().catch(console.error)
What previously was PIZZA_DATA_DIRECTORY
is now PEPPERONI_PIZZA_DATA_DIRECTORY
because of the additional PEPPERONI
env prefix. Looks pretty dumb, but read on and trust us, as env prefixing will come in handy!
Note, that env prefixing applies to module configs as well (see the Modules section).
Also, what you've just seen is an example of using a plugin. EnvPrefix.from()
constructs a new plugin
instance, which is then passed to the use()
method on the Chalupa builder. Chalupa will invoke the "used" plugins at
various events and lifecycle phases. You can find more information regarding the inner workings of plugins in
the Plugins and the Extending Chalupa sections.
While environment variables constitute the preferred source of configuration values (refer to The Twelve-Factor App: Config), there are instances when loading values from files is a better option. For this end, Chalupa comes with support for JSON and YAML configuration files.
Let's assume, that we have a configuration class as follows.
@Configuration()
class PizzaConfig {
@Configuration({
format: 'nat'
})
bakingTime = 180
}
Now, let's create a YAML configuration file as local.yml
.
pizza:
bakingTime: 269
# Let's configure logging as well.
log:
level: info
pretty: true
As the last step, we have to declare this file as a configuration source. Just as in the case of
the Environment Variable Prefix, we will use a plugin,
namely, ConfigSources
.
import { ConfigSources } from '@chalupajs/service'
async function start() {
const service = await Chalupa
.builder()
.use(ConfigSources.from(['local.yml']))
.createServiceWithStrategy(PizzaService, InMemoryStrategy)
await service.start()
}
start().catch(console.error)
Environment-dependent configuration loading works exactly the way you'd think.
const service = await Chalupa
.builder()
.use(ConfigSources.from([`${process.env['NODE_ENV']}.yml`]))
.createServiceWithStrategy(PizzaService, InMemoryStrategy)
Configuration sources are loaded in the order they appear in the configSources
array. Values are loaded on a *
last-value-wins* basis, which means that later configuration values overwrite the earlier ones.
A great way to achieve runtime traceability and observability is logging. Chalupa takes an SLF4J-like approach to logging by providing a unified logging facade with pluggable backends, such as pino and TSLog.
Just grab the LoggerFactory
, get a logger, and use the log method with the desired log level.
import { LoggerFactory, ILogger } from '@chalupajs/interface'
@Service()
class PizzaService {
private readonly logger: ILogger
// Since LoggerFactory is a concrete class, you don't have
// to use the @Inject decorator.
constructor(loggerFactory: LoggerFactory) {
this.logger = loggerFactory.getLogger(PizzaService)
logger.info('Yay, I was constructed!')
}
}
Notably, when calling getLogger
, you should pass the enclosing class or its name as the only argument to construct an
appropriate scoped logger instance.
Out of the box, Chalupa includes three logging backends:
You can set the desired provider using the LogProvider
plugin:
import { LogProvider } from '@chalupajs/service'
import { TSLogProvider } from '@chalupajs/logger-tslog'
async function start() {
const service = await Chalupa
.builder()
.use(LogProvider.provider(TSLogProvider))
.createServiceWithStrategy(PizzaService, InMemoryStrategy)
await service.start()
}
start().catch(console.error)
The logging API provides two configurable properties on the LogConfiguration
class (which is an ordinary konvenient
configuration class, just like the ones you can create):
- Level
- Possible values:
trace
,debug
,info
,warn
,error
,fatal
. - Default:
info
. - Env var:
LOG_LEVEL
. - Controls the minimum level of displayed log statements.
- Possible values:
- Pretty
- Boolean.
- Default:
false
. - Env var:
LOG_PRETTY
. - Controls whether the output is optimized for human or machine consumption.
Now, if no Environment Variable Prefix was set, then these two can be modified via the
above environment variables, LOG_LEVEL
and LOG_PRETTY
. However, if, for example, you use EnvPrefix.from('PIZZA')
,
then the respective environment variable names become PIZZA_LOG_LEVEL
and PIZZA_LOG_PRETTY
. Thus, you can safely run
two services in the same scope and set different log settings for them. Neat, huh?
In the previous sections (such as Environment Variable Prefix
and Loading Configuration Files) we already made use of plugins (think EnvPrefix
or ConfigSources
) without knowing what they were and how they worked.
Simply put, plugins extend and alter the behavior of the framework itself. To give a taste, they can do the following:
- hook into the Service Lifecycle,
- add, replace, remove or inspect Dependency Injection bindings,
- inspect and proxy instances in the dependency injection context,
- and much more!
If you want a more involved description (because, for example, you want to write your own plugins), then make sure to check the Extending Chalupa section. Here we will only cover their usage.
You can attach plugins to a service prior it's created. This is done via the use()
method of the Chalupa service
builder, which accepts one or more plugin instances. Therefore, the following two examples are equivalent:
await Chalupa.builder()
.use(new PluginA())
.use(new PluginB())
.createServiceWithStrategy(SomeService, SomeStrategy)
await Chalupa.builder()
.use([new PluginA(), new PluginB()])
.createServiceWithStrategy(SomeService, SomeStrategy)
When using plugins, be aware of the following gotchas:
- Order
- Plugins will be applied in the order they are
use
'd. This is relevant if the correct behavior of a plugin depends on some other plugin.
- Plugins will be applied in the order they are
- State
- You can share a plugin instance between multiple services. However, in that case, make sure to handle plugin state
correctly, to prevent surprises (and painful hours of debugging). Thus, this is completely fine, if the plugin is
stateless (or handles per-service state correctly):
const sharedPlugin = new SharedPlugin() await Chalupa.builder() .use(sharedPlugin) .createServiceWithStrategy(SomeService, SomeStrategy) await Chalupa.builder() .use(sharedPlugin) .createServiceWithStrategy(OtherService, SomeStrategy)
- You can share a plugin instance between multiple services. However, in that case, make sure to handle plugin state
correctly, to prevent surprises (and painful hours of debugging). Thus, this is completely fine, if the plugin is
stateless (or handles per-service state correctly):
If you're creating multiple services in the same process that share many plugins, then it might become cumbersome to use
the same set of plugins for each. That's when globalUse
comes handy:
/* 1. */
Chalupa.globalUse([new PluginA(), new PluginB()])
/* 2. */
await Chalupa.builder().createServiceWithStrategy(SomeService, SomeStrategy)
await Chalupa.builder().createServiceWithStrategy(OtherService, SomeStrategy)
globalUse
has the same signature, as the service builder'suse
method. Therefore, you can pass a single plugin or event multiple ones to it. You can callglobalUse
multiple times to add new plugins (subsequent invocations will extend the list o globall used plugins).- Both
SomeService
andOtherService
will use the globally addedPluginA
andPluginB
instance.
Note that gloal use'd plugins will precede the ones use'd on the service builder. This is relevant for order-sensitive plugins.
In the previous sections, we've already shown examples of dependency injection (think of the @Inject
decorator and
the inject
property). In what follows, we give a detailed description of the DI support of Chalupa.
If you're new to Dependency Injection, then these SO answers (and, most importantly, the links within them) can help to get a quick grasp on the topic:
Before diving in, we would like to start with a quick note. In Chalupa, most of the DI heavy-lifting is done by the excellent Inversify library. You can get by perfectly fine without knowing Inversify, since Chalupa provides its own decorators for DI, which are a subset of the Inversify decorators. Nevertheless, if you're limited by Chalupa's decorators, then feel free to use Inversify decorators directly.
With all of that out of the way, let's focus on DI now!
Regardless of the exact DI library in question, all implementations revolve around two key concepts: bindings and dependencies. A dependency is something that needs to be resolved or fulfilled, while a binding is something that can be used to fulfill dependencies. In Chalupa, you're expected to declare dependencies in constructors (and thus, enable constructor injection) and declare bindings in appropriate decorators. Let's see how!
A class binding is a binding of a concrete type to itself. Which means, that whenever a dependency is found on a * concrete class*, then an instance of the class itself will be used to resolve that dependency. Instantiation is handled by Chalupa.
Let's declare a class binding!
import { Injectable } from '@chalupajs/interface'
/* 1. */
@Injectable()
class PizzaAggregator {
}
@Service({
inject(context) {
/* 2. */
context.bindClass(PizzaAggregator)
}
})
class PizzaService {
}
- First, we created a new class,
PizzaAggregator
and decorated it with@Injectable
. This decorator marks a class as both an injection-target (something that can be injected into) and an injection-source (something that other injectables can inject into themselves). - Then, in the
inject
function of the@Service
options, we can access the context, which represents the DI container of our service. Using the context we simply bind thePizzaAggregator
class, effectively saying that " whenever you find a dependency onPizzaAggregator
, simply inject an instance".
Making use of this binding is then super easy, just look at the constructor:
import { Inject } from '@chalupajs/interface'
@Service({
inject(context) {
context.bindClass(PizzaAggregator)
}
})
class PizzaService {
constructor(@Inject(PizzaAggregator) pizzaAgg: PizzaAggregator) {
console.log('Look, ma, pizza aggregator', pizzaAgg)
}
}
In the case of class bindings, you may also omit the @Inject
decorator:
class PizzaService {
constructor(pizzaAgg: PizzaAggregator) {
console.log('Look, ma, pizza aggregator', pizzaAgg)
}
}
See Shorthand Notations for a shorter way to declare class bindings.
Binding classes is great, but generally, we want to depend on abstractions rather than concrete implementations. So, let us assume, that we have an interface and a corresponding implementing class as follows.
interface IPizzaRepository {
loadPizzas(): Pizza[]
}
@Injectable()
class MongoPizzaRepository implements IPizzaRepository {
loadPizzas() {
return []
}
}
Now, you may know, that TypeScript interfaces are like Java generics: they only exist in compile time and vanish in execution time. Since TypeScript is compiled down to JavaScript, and JavaScript has no notion of interfaces, TypeScript generates no code for interfaces. They are used for type checking only.
Therefore, when we want to declare dependency on an interface, we cannot write
@Inject(IPizzaRepository)
just as we did in the case of the PizzaAggregator
class (see Class Binding). This is so,
because IPizzaRepository
is not a value and has no representation in JavaScript.
Long story short, if we want to depend on an interface, we have to use a type key in both the binding and the dependency. This type key then can be either a string or a Symbol. Check!
@Service({
inject(context) {
/* 1. */
context.bindInterface('IPizzaRepository', MongoPizzaRepository)
}
})
class PizzaService {
constructor(/* 2. */ @Inject('IPizzaRepository') pizzas: IPizzaRepository /* 3. */) {
console.log('Yay, pizzas', pizzas)
}
}
- As you can see, in this case, we used the
bindInterface
method of the context. This allows us to define the type key along with the concrete implementation that should be used to resolve dependencies with that very key. Here we essentially said the following to Chalupa: "whenever you find an@Inject
with theIPizzaRepository
key, resolve that dependency with aMongoPizzaRepository
instance". - Afterward, in the constructor, we can declare that we depend on the
IPizzaRepository
abstraction by using the same key in the@Inject
decorator as in the binding. - Then, the type of the parameter is what you'd expect: the
IPizzaRepository
interface.
To prevent typos, it's best to extract the injection key into a constant.
const TYPES = {
IPizzaRepository: 'IPizzaRepository'
}
So far, we've left the task of instantiation to the DI container by always binding class constructors (both in the case of Class Bindings and Interface Bindings). In numerous situations, however, for example, when we want bind primitive values like a simple string or a number, we want to bind an exact, already available value or instance. This is the problem solved by constant bindings.
Similarly to the already known binding techniques, we can use the inject
function and the context again. Assuming that
we want to inject a simple string value into the container, we can write something like below.
@Service({
inject(context) {
/* 1. */
context.bindConstant('TODAYS_PIZZA_CHEF', 'Giovanni')
}
})
class PizzaService {
constructor(/* 2. */ @Inject('TODAYS_PIZZA_CHEF') chef: string) {
/* 3. */
console.log(chef)
}
}
- Using the
bindConstant
method of the context, we bind the primitive valueGiovanni
to theTODAYS_PIZZA_CHEF
key. This is equivalent to saying the following to Chalupa: "wherever you see a dependency on theTODAYS_PIZZA_CHEF
key, inject the valueGiovanni
". 1. We express our dependency on the constant value using the well-knownInject
decorator and theTODAYS_PIZZA_CHEF
key. 1.Giovanni
gets injected and printed, just as expected.
Again, to prevent hours of DI debugging, it is a good idea to extract the key into a named constant:
const CONSTANTS = {
TODAYS_PIZZA_CHEF: 'TODAYS_PIZZA_CHEF'
}
See Shorthand Notations for a shorter way to declare constant bindings.
Even when developing simpler services, the situation may arise when you want to conditionally bind a concrete implementation to some abstraction. For example, let's imagine that we're developing a service that needs to store files somewhere. In staging and production, we would like to use S3. However, locally, we don't want to mess around with S3, thus, we decided to write a simple file system-based implementation. Now, how can we select the appropriate implementation based on the current environment?
That's where configuration-dependent binding comes into the picture. First, we set the scene by creating the FileStore
interface and the two implementations, S3 and file system-based.
interface IFileStore {
}
@Injectable()
class S3FileStore implements IFileStore {
}
@Injectable()
class FsFileStore implements IFileStore {
}
Then, we follow this up by creating a konvenient configuration class with a configurable env
property. We will use
this property to choose the actual runtime store implementation.
@Configuration()
class FileStoreConfig {
@Configurable({
doc: 'The environment the application is executing in.',
format: ['local', 'staging', 'production'],
})
env = 'production'
isLocalEnv() {
return this.env === 'local'
}
}
By default, the value of this property will be read from the FILE_STORE_ENV
environment variable (which can be altered
by, for example, with an Environment Variable Prefix).
We reached our last and most important step, the actual binding.
@Service({
inject(context) {
/* 1. */
const fileStoreConfig = context.immediate(FileStoreConfig)
/* 2. */
const fileStoreImpl: Constructor<IFileStore> = fileStoreConfig.isLocalEnv()
? FsFileStore
: S3FileStore
/* 3. */
context.bindInterface('IFileStore', fileStoreImpl)
}
})
class FileStoreService {
}
- The first step is immediately (haha, got 'em) the most important one. The
immediate
method of the context is a special form ofbindClass
: it creates a Class Binding, instantiates the class and returns the instance immediately (hence the name). - Thanks to the
immediate
call, we will have access to a populated instance of theFileStoreConfig
configuration class. We can then use this instance to actually decide which implementation should be used. The decision will always be made in runtime, based on the value offileStoreConfig.isLocalEnv()
, which, in turn, usesfileStoreConfig.env
(FILE_STORE_ENV
). - In the previous step, we selected which implementation should be used. In this step, we make the actual binding, by
calling
bindInterface
with the appropriate type key and class.
Of course, classes bound using immedate
can be injected just as if they were bound using bindClass
.
@Service({
inject(context) {
context.immediate(FileStoreConfig)
}
})
class FileStoreService {
constructor(fileStoreConfig: FileStoreConfig) {
console.log('Received FileStoreConfig', fileStoreConfig)
}
}
Since immediate
calls perform binding and instantiation at the same time, they are best suited for classes with no or
isolated dependencies. Great examples are configuration classes, which, in 99% of the time, have no dependencies. While
you might find other uses for immediate
, other than configuration, please keep in mind: wth great power comes great
responsibility.
It might be tempting to use this technique for testing purposes: injecting a test double based on some configuration conditions. However, that would pollute our normal application code with testing related logic. Therefore, Chalupa provides a much more elegant solution, detailed in the Testing section.
@Service({
inject(context) {
/* 1. */
context.bindDynamicValue('TODAYS_PIZZA_CHEF', () => 'Giovanni')
}
})
class PizzaService {
constructor(/* 2. */ @Inject('TODAYS_PIZZA_CHEF') chef: string) {
/* 3. */
console.log(chef)
}
}
In the previous sections, we always assumed singular bindings: that is, that we have a single value bound to a given key or type. Usually, this is exactly what we want. Nevertheless, there are situations in which we have an interface with multiple implementations that exist simultaneously, and we want to access every implementation together. Enter * multi-injection*!
As a concrete example, let's assume, that we're writing a service that collects the daily offerings of several local restaurants. Each restaurant distributes its daily offering in a different manner, thus, we have to write an implementation on a per-restaurant basis. To make our lives easier, we will extract a common interface for these implementations.
interface DailyOffering {
restaurant: string,
items: string[]
}
interface IDailyOfferingScraper {
scrapeOffering(): Promise<DailyOffering>
}
@Injectable()
class KingPadliDailyOfferingScraper implements IDailyOfferingScraper {
async scrapeOffering() { /*...*/
}
}
@Injectable()
class BlahaneDailyOfferingScraper implements IDailyOfferingScraper {
async scrapeOffering() { /*...*/
}
}
Clearly, if we want to check all the offerings together, then we have to somehow call the scrapeOffering
method of
each implementation. However, how can we access each implementation? That's exactly, where multi-injection comes into
the picture.
import { MultiInject } from '@chalupajs/Chalupa'
@Service({
inject(context) {
/* 1. */
context
.bindInterface('IDailyOfferingScraper', KingPadliDailyOfferingScraper)
.bindInterface('IDailyOfferingScraper', BlahaneDailyOfferingScraper)
}
})
class DailyOfferingService {
constructor(/* 2. */ @MultiInject('IDailyOfferingScraper') scrapers: IDailyOfferingScraper[] /* 3. */) {
console.log(scrapers)
}
}
- First, we bind all of our implementations to the same key,
IDailyOfferingScraper
. 1. Instead of the usual@Inject
decorator, we use@MultiInject
to tell the framework that we are expecting multiple values here. By parameterizing the injection with theIDailyOfferingScraper
key, we explicitly state what should be injected here. 1. The type of the multi-injected parameter is not a singular value, but an array. The framework will populate this array with every instance bound to theIDailyOfferingScraper
key.
Given a pre-existing binding for some type or type key (created with a Class Binding,
an Interface Binding or a Constant Binding), it can be replaced by an
appropriate rebind
call: rebindClass
, rebindInterface
or rebindConstant
.
Let's assume, that give some configuration, we want to replace an interface binding with some pre-created, instrumented object. In that case, we can use rebind as follows:
@Service({
inject(context) {
context.bindInterface('TYPE', NormalImplementation)
if (context.immediate(RebindConfig).shouldReplaceImplementation()) {
context.rebindConstant('TYPE', new InstrumentedImplementation())
}
}
})
class RebindService {
}
While the above example might seem a little contrived, rebind is a great tool when interfacing with or authoring Modules and plugins (see Plugins and Extending Chalupa).
When rebinding a type key with multiple bound implementations, each previous binding will be dropped:
@Service({
inject(context) {
context
.bindInterface('TYPE', ImplOne)
.bindInterface('TYPE', ImplTwo)
context
.rebindConstant('TYPE', new RebindImpl())
}
})
class RebindService {
}
In the above case, both the ImplOne
and ImplTwo
binding will be dropped, and only the RebindImpl
instance will be
bound to TYPE
.
Rebinding corresponds to two operations in one: unbinding and binding. Unbinding removes a pre-existing
binding for a given class or type key. The existence of an appropriate binding can be checked using the isBound
method, as shown in the example below.
@Service({
inject(context) {
if (context.isBound('TYPE')) {
context.unbind('TYPE')
}
}
})
class RebindService {
}
Again, unbinding shines the most when one has to deal with Modules and plugins (see Plugins and Extending Chalupa).
If multiple values are bound to the same type key, then unbinding that very type key will drop each binding (the same way as in the case of Rebinding).
Dependencies and bindings are all good, but one crucial step is still missing. How do all these things come to life? When and how do our classes get instantiated?
The underlying container of Chalupa is lazy. This means, that instances are created on-demand, only when requested. Thus, if we want the container to actually create an instance of some type, we have to request that very type from the container. Of course, instances can only be obtained if all of their dependencies are satisfied. Consequently, the container attempts to resolve the dependencies of the requested type first. For this end, it examines the available bindings. If no binding can resolve the dependency, then an error is thrown. However, if an appropriate binding is found, then the container instantiates it, if necessary. The bound type that we want to instantiate, in turn, can have its own dependencies and so on. The chain continues until the container finds a dependency which is already available or can be immediately constructed. Then, the container walks back the chain, instantiating and injecting everything, eventually finishing off with the type that was originally requested.
An important consequence of the lazy behavior is runtime errors: if a given dependency is not satisfied by any of the bindings, then it is only discovered at runtime when we want to resolve the dependency in question. Can we construct containers that perform dependency resolution at compile time? Sure, one example is the Dagger framework for the JVM.
In Chalupa, the class decorated with @Service
can be thought of as some kind of a root or origin: generally, every
other type in the container is reachable from this class through various dependency chains. Therefore, when we request
an instance of the @Service
class from the container, essentially everything gets instantiated and just like in a
puzzle, falls into its place.
One question still lingers around. What happens if a class is a dependency of multiple classes? How many instances will the container create? In Chalupa, every binding is singleton, meaning, that a class is instantiated only once and that one instance is then reused to resolve each and every dependency on the type. This enables two important patterns:
- Exclusive ownership of resources. For example, only a single instance will ever exist of your database connection class throughout the lifespan of your application.
- Stateless components. As instances are shared among many dependents, if they are not guarding some resource then it's best to keep them stateless to prevent surprises, when one dependent class sees the effects of another, completely unrelated class.
If you only want to perform Class Bindings in the inject
function, then you can use the shorthand
array form, as follows.
@Injectable()
class PizzaOven {
}
@Injectable()
class DeliveryGuy {
}
@Service({
inject: [PizzaOven, DeliveryGuy]
})
class PizzaShopService {
}
The above is the exact same as
@Service({
inject(context) {
context
.bindClass(PizzaOven)
.bindClass(DeliveryGuy)
}
})
class PizzaShopService {
}
but it saves you a few keystrokes.
Additionally, constant bindings have their own shorthand notation, the constants
option. This option can be used in
conjunction with either form of the inject
property.
@Service({
inject: [PizzaOven],
constants: [
['TODAYS_PIZZA_CHEF', 'Giovanni'],
['EXPECTED_DELIVERY_TIME', 69]
]
})
class PizzaShopService {
}
constants
should be assigned an array of two-element arrays: the first element is the key, while the second element is
the value bound to the key. The above is the exact same as
@Service({
inject(context) {
context
.bindConstant('TODAYS_PIZZA_CHEF', 'Giovanni')
.bindConstant('EXPECTED_DELIVERY_TIME', 69)
}
})
class PizzaShopService {
}
Modules also have their own shorthand form, which can be used in conjunction with the inject
option:
@Service({
inject: [PizzaOven],
modules: [DeliveryModule, PaymentModule]
})
class PizzaShopService {
}
Important: If inject
is a function, then it will be executed last. Therefore, the bindings created by constants
and modules
will be available by the time it runs.
So far, we've seen the two opposite ends of the granularity spectrum. On one end, we find services that are self-contained and executable, offering facilities to other services. Then, on the other end, we have individual classes and interfaces. This implies, that there must be something in between, right? Something, that is smaller than a service but larger than an individual class.
Enter the notion of modules! A module is similar to a service in the sense, that it has its own bindings, lifecycle phases and such. However, in itself, it does not correspond to an executable entity. Before discussing their purpose, let's check out how to declare them.
import { Module } from '@chalupajs/interface'
/* 1. */
@Module({
/* 2. */
inject(context) {
},
constants: [],
modules: []
})
class DeliveryModule {
}
- The most important part is the
@Module
decorator which marks a class as a module. - Modules can declare their own bindings using the very same
inject
,constants
andmodules
facilities ( see Dependency Injection).
Once we have a module, let's add it to a service!
@Service({
modules: [DeliveryModule]
})
class PizzaService {
}
By binding a module in the inject
function or the modules
array, we achieve the following. When creating and
starting the service
- the bindings of the module are added to the context of the service (see Dependency Injection) ,
- the service methods and events of the module are added to the service's network interface ( see Inbound Communication)
- the lifecycle methods of the module will be called when appropriate (see Lifecycle).
Overall, modules can be thought of as mini-services that need a host service to operate.
Now, let's look at the elephant in the room: why would you want to create modules? We have at least two great reasons:
- Splitting up service interfaces. While you should strive to keep your services thin and focused with as few methods and events as possible, in some situations, a service may need to offer a variety of different methods. In such cases, you can extract each group of logically related methods into their own module. Since modules have their own configuration and bindings, you can even move the necessary configuration and dependencies to the module level. By using this technique, you can separate your service into cohesive subcomponents which can be easily extracted into their own service in the future, if necessary.
- Code reuse. Modules can be published in npm packages, which allows for cross-service code reuse. Writing a database connector? Slap it into a module! Health check and metrics? Another module! Any Chalupa-specific code should reside in modules, as modules automatically provide configuration, logging, dependency injection, lifecycles and even service methods and events. Code that is independent from Chalupa services should still be placed in ordinary libraries, however, if you plan on integrating with Chalupa, then a module is a perfect choice.
Observe, that modules can bind other modules too (as the @Module
decorator has the modules
option). Therefore, the
containing service and the modules form
a directed acyclic graph (DAG), in which the nodes are the
modules (and the service itself) while the edges are the depedency or "who bound who" relationships. We have a DAG, and
not a tree, since cases may occur, in which multiple modules bind (and thus, depend on) the same module.
Let's assume the following scenario:
@Module()
class A {
}
@Module({modules: [A]})
class B {
}
@Module({modules: [A]})
class C {
}
@Service({modules: [B, C]})
class Root {
}
In this case, the module graph looks as below. The arrows point from the dependent item to the dependency.
Even though two modules listed A
as a dependency, it will only be instantiated once. This is a general rule for
modules: each and every module is a singleton, regardless of how many dependents it has. Consequently, the inject
function of a module is only executed once (and thus, every binding of the module is added once).
The modules
property is not the only way of declaring modules. The context parameter of the inject
function also
provides a bindModule
method which can be used as follows:
@Module({
inject(context) {
context.bindModule(OtherModule)
}
})
class SomeModule {
}
This method allows for dynamically binding modules, for example, based on configuration values (as in the case of Configuration-dependent Bindings).
Returning to a previous example, we want to construct a service which stores files somewhere. In production, we want to use S3, while locally, the file system suffices. We can create two modules along with a configuration class:
@Module()
class S3FileStoreModule {
}
@Module()
class FsFileStoreModule {
}
@Configuration()
class FileStoreConfig {
@Configurable({
doc: 'The environment the application is executing in.',
format: ['local', 'staging', 'production'],
})
env = 'production'
isLocalEnv() {
return this.env === 'local'
}
}
Then, we can conditionally select the appropriate module in the inject
function of the service:
@Service({
inject(context) {
const fileStoreConfig = context.immediate(FileStoreConfig)
const fileStoreModule = fileStoreConfig.isLocalEnv()
? FsFileStoreModule
: S3FileStoreModule
context.bindModule(fileStoreModule)
}
})
class FileStoreService {
}
Let's assume the following scenario. You want to connect to some HTTP API so you create an interface, named HttpApi
.
To make this machinery reusable across your Chalupa services, you put this interface (along with its implementation)
into a new module, HttpApiModule
. Everything works fine, until the API is changed: the same endpoints still work,
however, a client token is now required to avoid getting rate limited. While you can modify your existing
implementation, it does not seem to be the most elegant solution: the client token is not absolutely necessary and it is
an authentication aspect. What else can be done?
From the Dependency Injection section, we might remember two more involved
facilities: Rebinding and Unbinding. Using these two, we can implement a decorator module,
which builds on our pre-existing HttpApiModule
while extending its functionality.
class AuthenticatedHttpApiImpl implements HttpApi /* 1. */ {
/* 2. */
private readonly impl: OriginalHttpApiImpl;
constructor() {
this.impl = new OriginalHttpApiImpl()
}
}
@Module({
inject(context) {
/* 3. */
context.bindModule(HttpApiModule)
/* 4. */
context.rebindInterface('HttpApi', AuthenticatedHttpApiImpl)
}
})
class AuthenticatedHttpApiModule {
}
- Our new implementation (which adds a client token to each HTTP request) implements the original interface,
HttpApi
. Therefore, dependent classes will see no difference, as they depend on the interface and not the actual implementation. - Since we do not want to re-implement functionality that worked before, we make use of existing
HttpApi
implementation, calledOriginalHttpApiImpl
. The new implementation will delegate calls to an instance of this class. - In the
inject
function of the new module, we bind the original module,HttpApiModule
. After thebindModule
call returns, we can be sure, that the bindings ofHttpApiModule
are available in the context. - Therefore, we can rebind the
HttpApi
type key to our new implementation, essentially overriding the previous binding.
The key takeaway here is the fact that once bindModule
returns, all the bindings of the bound module are available.
Which means, that they can be freely rebound or even unbound.
In what follows, we detail how to accept requests and events from other services, as well as, how to invoke them the Chalupa way.
Communication between services comes in two flavors: request-reply and event-based. Handlers for the former are called * service methods*, while listeners for the latter are service events (surprisingly).
import { Service, ServiceMethod, ServiceEvent } from '@chalupajs/interface'
@Service()
class PizzaService {
@ServiceMethod()
async getNumberOfOrdersSince(since: number): Promise<number> {
// Handle the request and return the reply, in this case,
// a Promise<number>.
}
@ServiceEvent()
async pizzaOrdered(flavor: string) {
// Handle the event. This method is Promise<void>, as
// event handlers do not need to return anything.
}
}
As Chalupa is decorator-driven, service methods and events can be registered using the appropriate
decorators: @ServiceMethod
and @ServiceEvent
. The external name (visible to other services) of the method/event is
going to be the name of the decorated method, unless otherwise specified. In the case of the above example, other
services can call the PizzaService.getNumberOfOrdersSince
method or emit to the PizzaService.pizzaOrdered
event.
The automatically assigned names can be overridden (both for methods and events). In the following example, the method
visible to other services will be ordersSince
instead of getNumberOfOrdersSince
.
@Service()
class PizzaService {
@ServiceMethod({
name: 'ordersSince'
})
async getNumberOfOrdersSince(since: number): Promise<number> {
// Handle the request and return the reply, in this case,
// a Promise<number>.
}
}
Modules also support methods and services, using the same decorators.
@Module()
class DeliveryModule {
@ServiceMethod()
async deliverPizza(pizza: Pizza): Promise<Delivery> {
// Deliver the pizza!
}
}
When the DeliveryModule
is bound in the context, the above deliverPizza
method will automatically be available as
part of the service's published interface. Note, that module methods and events are always published regardless of the
actual place where the module was bound. Thus, if a module was bound by another module, the methods and events will
still be available.
When a ServiceMethod
want to handle an action based on the current context, then we have the so called Terms Object
for that purpose.
import { TermsObject } from '@chalupajs/interface'
@Module()
class DeliveryModule {
@ServiceMethod()
async deliverPizza(pizza: Pizza, @TermsObject() _terms: Record<string, any>): Promise<Delivery> {
// Deliver the pizza!
}
}
Of course, you can change the Record
into any interface you want.
Being called is great, but being able to call is even more fun! Calling other services is done through so-called * external services*.
For each external service, you have to create a corresponding class as follows.
import { ExternalService, ExternalServiceTemplate } from '@chalupajs/interface'
/* 1. */
@ExternalService()
class Cache extends ExternalServiceTemplate /* 2. */ {
}
The two requirements for external services are
- the
@ExternalService
annotation (which offers aname
option, similar to that of@ServiceMethod
) - and the
ExternalServiceTemplate
base class.
Now you're ready to make calls to the Cache
service!
@Service({
/* 1. */
inject: [Cache]
})
class PizzaService {
private readonly cache: Cache
constructor(/* 2. */ cache: Cache) {
this.cache = cache
}
@ServiceMethod()
async getNumberOfOrdersSince(since: number): number {
/* 3. */
await this.cache.request('getItem', 'pizza-orders')
}
}
Whoa, now, that's a lot, so let's break it down!
- First, we have to register the
Cache
external service by adding it to theinject
array (we could've used abindClass
Class Binding as well). This tells Chalupa that you depend on the Cache service (so that your service will wait forCache
to show up when started), and it can load and instrument the external service class. - Then, we have to inject an instance of this external service into our service. This instance will be created by Chalupa and will be automatically injected into our constructor when necessary.
- Finally, we can make a network request to
Cache.getItem
(published by theCache
service) using therequest
method. Therequest
method is provided by teExternalServiceTemplate
base class.
Our dependency on the Cache
service is now completely apparent. However, the getItem
call still feels a bit unsafe.
import { ExternalServiceMethod, IExternalServiceCall, serviceMethodPlaceholder } from '@chalupajs/interface'
@ExternalService()
class Cache extends ExternalServiceTemplate {
@ExternalServiceMethod()
getItem: (key: string) => IExternalServiceCall<any> = serviceMethodPlaceholder
}
By utilizing the @ExternalServiceMethod
decorator, and placing it on an appropriately typed property, we can instruct
Chalupa to automatically generate typed request calls for us. The anatomy of an external service method is as follows:
@ExternalServiceMethod()
<name of the method>: (parameters) => IExternalServiceCall<result type> = serviceMethodPlaceholder
Note: The
serviceMethodPlaceholder
value is necessary only to make the TypeScript compiler happy.
Now we can call this method as follows.
@Service({
inject: [Cache]
})
class PizzaService {
private readonly cache: Cache
constructor(cache: Cache) {
this.cache = cache
}
@ServiceMethod()
async getNumberOfOrdersSince(since: number): number {
await this.cache.getItem('pizza-orders').send()
}
}
Be aware of the send()
call at the end, which actually performs the request and waits for the reply. This is
necessary, since the return type of an external service method is always IExternalServiceCall<T>
. This is essentially
a command object, making it possible to customize how the request is made, and to pass around the request before it's
fired.
Of course, we can also declare events in a fashion similar to requests. Just use the @ExternalServiceEvent
decorator
and its friends, IExternalServiceEmit
and serviceEventPlaceholder
.
import { ExternalServiceEvent, IExternalServiceEmit, serviceEventPlaceholder } from '@chalupajs/interface'
@ExternalService()
class PizzaAggregator extends ExternalServiceTemplate {
@ExternalServiceEvent()
pizzaServed: (flavor: string, customer: string) => IExternalServiceEmit = serviceEventPlaceholder
}
Therefore, the anatomy of an external service event is as follows:
@ExternalServiceEvent()
<name of the event>: (parameters) => IExternalServiceEmit = serviceEventPlaceholder
Just as in the case of Inbound Communication, outbound communication via external services is
also supported by Modules. Assuming, that we want to use the Cache
service from a module, we can write the
following code.
@Module({
inject: [Cache]
})
class DeliveryModule {
private readonly cache: Cache
constructor(cache: Cache) {
this.cache = cache
console.log('Ready to cache!', Cache)
}
}
@Service({
inject: [Cache]
})
class PizzaService {
private readonly cache: Cache
constructor(cache: Cache) {
this.cache = cache
}
@ServiceMethod()
async getNumberOfOrdersSince(since: number): number {
// Generic request without ExternalServiceEvent
await this.cache.request('getItem', 'pizza-orders', { user: 1 })
//----- OR -----
// ExternalServiceEvent
await this.cache.getItem('pizza-orders').send({ user: 1 })
//----- OR -----
await this.cache.getItem('pizza-orders').withTerms({ user: 1 }).send()
}
}
When services become available (or, the opposite, unavailable), Chalupa emits the following events across the available services:
ServiceAppeared(name)
- Emitted when the
name
service becomes available on the network.
- Emitted when the
ServiceDisappeared(name)
- Emitted when the
name
service becomes unavailable on the network.
- Emitted when the
Subscribing to these events can be done via the appropriate decorators.
import { ServiceAppeared, ServiceDisappeared } from '@chalupajs/interface'
@Service()
class PizzaService {
@ServiceAppeared()
async appeared(name: string) {
console.log(`Hello, ${name}!`)
}
@ServiceDisappeared()
async disappeared(name: string) {
console.log(`Goodbye, ${name}!`)
}
}
When declaring listeners, please be aware of the following limitations:
- These decorators are not available on Modules yet.
- On the same service, there can only be a single listener for the same event. This means, that you cannot add multiple
listeners for the
ServiceAppeared
event on the same service.
These limitations might be lifted in the future.
In-memory strategy.
The configurable properties of the Darcon connection are as follows:
- Division
DARCON_DIVISION
- Default:
Chalupa
.
- Identifier Length
DARCON_ID_LENGTH
- Default:
16
- Response Tolerance
DARCON_RESPONSE_TOLERANCE
- Default:
30000
- Reporter Interval
DARCON_REPORTER_INTERVAL
- Default:
2000
- Keeper Interval
DARCON_KEEPER_INTERVAL
- Default:
10000
- Maximum Reconnect Attempts
DARCON_MAX_RECONNECT_ATTEMPTS
- Default:
-1
- Reconnect Time Wait
DARCON_RECONNECT_TIME_WAIT
- Default:
250
- Connect Time Wait
DARCON_CONNECT_TIME_WAIT
- Default:
2500
- Connection Patience
DARCON_CONNECTION_PATIENVE
- Default:
-1
- Strict
DARCON_STRICT
- Default:
false
- Communication Size
DARCON_COMM_SIZE
- Default:
500000
- Maximum Communication Size
DARCON_MAX_COMM_SIZE
- Default:
2500000
- NATS URL
DARCON_NATS_URL
- Default:
'nats://localhost:4222'
If you use an Environment Variable Prefix on your service, then the above environment variables will be prefixed with the chosen value. For example, if the env prefix is
NOTIFICATION
, thenDARCON_NATS_URL
becomesNOTIFICATION_DARCON_NATS_URL
.
If you want to access the above values directly in your application (for whatever reason), then simply inject an
instance of DarconConfig
.
IPC strategy.
When implementing more involved services, we regularly want to perform additional initialization logic before the service is published, and, conversely proper teardown before it stops. A great example is the handling of database connections: we want to open a connection before accepting requests and then want to close it once we're done.
Fortunately, Chalupa provides facilities for both services and Modules to perform both initialization and teardown. Let's first see the case of services.
import { PostInit, PreDestroy } from '@chalupajs/interface'
@Service()
class PizzaService {
@PostInit()
postInit() {
}
@PreDestroy()
preDestroy() {
}
}
Then, the options for modules.
import { PreServiceInit, PostServiceInit, PreServiceDestroy, PostServiceDestroy } from '@chalupajs/interface'
@Module()
class DeliveryModule {
@PreServiceInit()
preServiceInit() {
}
@PostServiceInit()
postServiceInit() {
}
@PreServiceDestroy()
preServiceDestroy() {
}
@PostServiceDestroy()
postServiceDestroy() {
}
}
All good, all fine, but when do these methods get called exactly?
- Initialization methods (those with
init
in their name) are called after each external service dependency of the current service is available and the delayed start has elapsed, but before the service is published on Darcon. - Teardown methods (those with
destroy
in their name) are called after service closure has been requested, but * before* the service is removed from Darcon.
The order then is as follows:
- The
@PreServiceInit
method of every module declared on the service. - The
@PostInit
method of the service. - The
@PostServiceInit
method of every module declared on the service. - The
@PreServiceDestroy
method of every module declared on the service. - The
@PreDestroy
method of the service. - The
@PostServiceDestroy
method of every module declared on the service.
Please be aware of the following limitations regarding lifecycle methods:
- In a given module or service, each lifecycle method can only be used at most once.
Frequently, we start by implementing the happy case of our business logic first. Then, by the time we add error handling for each and every failure scenario, the original happy path is almost impossible to recognize and follow. Chalupa attempts to help you in decluttering your code and extracting cross-cutting concerns by providing support for framework-level error handling.
Let's assume that we have some service method, orderPizza
, which accepts the flavor of the pizza. Now, if we receive
an unknown failure, then we would like to return an error response. The simplest solution can be something like this:
@Service()
class PizzaService {
@ServiceMethod()
async orderPizza(flavor: string): Promise<string> {
if (!['margherita', 'hawaii'].includes(flavor)) {
return `Unknown flavor: ${flavor}`
}
return 'enjoy your pizza!'
}
}
This solution is great, however, it does not scale very well (what if we want to reuse this validation logic?) and the
body of our orderPizza
method much longer than needed.
Let's try something else! We can introduce a custom error for unknown flavors:
class UnknownPizzaFlavorError extends Error {
constructor(readonly flavor: string) {
super(`Unknown flavor: ${flavor}`)
}
}
Follow this up by extracting the validation code into a separate method:
@Service()
class PizzaService {
@ServiceMethod()
async orderPizza(flavor: string): Promise<string> {
guardPizzaFlavor(flavor)
return 'enjoy your pizza!'
}
private guardPizzaFlavor(flavor: string) {
if (!['margherita', 'hawaii'].includes(flavor)) {
throw new UnknownPizzaFlavorError(flavor)
}
}
}
The code feels much cleaner now: guardPizza
does exactly one thing, and the level of abstraction in orderPizza
is
consistent. However, there's one, not-so-little problem still: who's going to handle the UnknownPizzaFlavorError
,
thrown from guardPizzaFlavor
? That's where Chalupa's support for error handling comes into the picture. Watch!
import { ErrorHandler } from '@chalupajs/interface'
@Service()
class PizzaService {
@ServiceMethod()
async orderPizza(flavor: string): Promise<string> {
this.guardPizzaFlavor(flavor)
return 'enjoy your pizza!'
}
/* 1. */
@ErrorHandler(/* 2. */ UnknownPizzaFlavorError)
private async onUnknownPizzaFlavor(/* 3. */ error: UnknownPizzaFlavorError): Promise<String> {
return error.message
}
private guardPizzaFlavor(flavor: string) {
if (!['margherita', 'hawaii'].includes(flavor)) {
throw new UnknownPizzaFlavorError(flavor)
}
}
}
- We created a new method in
PizzaService
and decorated it with@ErrorHandler
. - We passed the error type that we want to handle as an argument to the
@ErrorHandler
decorator. Every time an error is thrown from a service method or a service event (see Outbound Communication), Chalupa will inspect the error handlers in the same service/module class. If the thrown error is an instance of the handled type of some error handler, then that handler will be called. - The type of the parameter is
UnknownPizzaFlavorError
as we can be sure, that we always receive an instance of that type.
Please be aware of the following gotchas regarding error handling:
- Scoped
- Error handlers are scoped. This means, that a given error handler can only catch errors thrown in the same class.
Therefore, this will not work:
In the future, this limitation is going to be lifted in some way, allowing for global error handlers.
@Module() class A { @ServiceMethod() async throwing() { throw new Error() } } @Module() class B { @ErrorHandler(Error) async globalHandler(error: Error) { console.log(error) } }
- Error handlers are scoped. This means, that a given error handler can only catch errors thrown in the same class.
Therefore, this will not work:
- Catch multiple types.
- An error handler can catch multiple error types. Simply use an array parameter in the
@ErrorHandler
decorator.@Service() class SomeService { @ErrorHandler([SomeError, OtherError]) private async onSomeOrOtherError(error: SomeError | OtherError) { console.log(error) } }
- An error handler can catch multiple error types. Simply use an array parameter in the
- Ordered
- When attempting to find an error handler for a thrown error, Chalupa will enumerate the error handlers in the
order of their declaration. Therefore, this is correct:
While this is most likely not what you want (since
@Service() class SomeService { @ErrorHandler(SomeError) private async onSomeError() {} @ErrorHandler(Error) private async onError() {} }
onSomeError
will never be called):@Service() class SomeService { @ErrorHandler(Error) private async onError() {} @ErrorHandler(SomeError) private async onSomeError() {} }
- When attempting to find an error handler for a thrown error, Chalupa will enumerate the error handlers in the
order of their declaration. Therefore, this is correct:
- Async
- Error handlers can be asynchronous.
While unit testing is rather straightforward (just import the appropriate unit into the test and replace its dependencies with test doubles), higher level tests are not necessarily so. To increase our confidence in the correctness of our service, we may want to perform narrow integration tests against it: using its public API, but replacing its external dependencies with doubles.
For more information or narrow integration tests, please refer to
In this process, we want to make sure that our service behaves the same way as if it was made available on the network by Chalupa:
- modules are correctly attached,
- configuration is loaded,
- the container is setup,
- lifecycle methods are called,
- etc.
While, at the same time, we have a bit more control to ease testing:
- we can modify configuration values (without having to touch the environment variables),
- we can rebind types in the container to replace them with test doubles.
All of the above can be achieved with the IntegrationTestBuilderStrategy
, offered by Chalupa. This is an alternative
to the ordinarily used ServiceBuilderStrategy
, which actually publishes a service to Darcon.
In what follows, we introduce the testing support through an example. Let's assume, that we're writing a greeting
service that exposes a greeter method. This method returns a greeting, appropriate to the current time of day. The
current time, in turn, is supplied by an external service, called DateTime
. All of these can be implemented as shown
below.
@ExternalService()
class DateTime extends ExternalServiceTemplate {
@ExternalServiceMethod()
hours: () => IExternalServiceCall<number> = serviceMethodPlaceholder
}
@Service({
inject: [DateTime],
})
class GreetingService {
private readonly dateTime: DateTime
constructor(dateTime: DateTime) {
this.dateTime = dateTime
}
async greet(who: string): Promise<string> {
const hours = await this.dateTime.hours().send()
return hours < 12 ? `Good morning, ${who}!` : `Good evening, ${who}!`
}
}
Now, let's say, that we want to test if the greet
method returns Good morning
when called before noon. Then, we can
write the skeleton of our test somehow like this:
describe('GreetingService.greet', () => {
it('should return "Good morning" when called before noon.', async () => {
// Given
// When
// Then
})
})
First, we need to arrange the scene, by
- declaring the test's inputs and expected output,
- setting up a known, testable state of the system under test (SUT).
// Given
/* 1. */
const HOUR = 9
const WHO = 'Jocky'
const expected = 'Good morning, Jocky!'
/* 2. */
const arrangement = await Chalupa
.builder()
.createServiceWithStrategy(GreetingService, IntegrationTestBuilderStrategy)
const sut = await arrangement
/* 3. */
.rebindConstant(DateTime, {
hours() {
/* 4. */
return CallWithResult.of(HOUR)
},
})
/* 5. */
.start()
Let's break this down, step by step!
- First, we declare the test data: the hour which will be returned by our
DateTime
double, the name passed to thegreet
method and the expected output. - This is followed by the creation of our test arrangement. We use the
createServiceWithStrategy
method with theIntegrationTestBuilderStrategy
to create a representation of the service, which is optimized for testing. This is called an arrangement, and allows us for binding and rebinding container types. - Using the
rebindConstant
method of the arrangement, we bind a test double to theDateTime
external service. Thus, when we start the service in step 5, the container will inject our double, instead of the original implementation. - The double will respond with the same value to each
hours
call. Remember, that external service methods returnIExternalServiceCall<T>
's, hence, we have to use theCallWithResult
helper type. - As the last step of our
Given
block, we call thestart
method of our arrangement, producing a testable system under test. The call to thestart
method will actually kick-off the service but will not publish it on the network.
Note: in the above example, we use Rebinding to completely replace the binding for
DateTime
. If you want to create new bindings in the context of the tested service, then use thebind
family of methods on the arrangement.
We're now ready to make a test call!
// When
const actual = await sut
/* 1. */
.getServiceOrModule(GreetingService)
/* 2. */
.greet(WHO)
- Using the
getServiceOrModule
method, we retrieve an instance ofGreetingService
from the context. As the name of the method implies, you can also retrieve any module from the context. - Then, we simply call the
greet
method as we would do in the case of an ordinary TypeScript method, and record its return value.
Finally, let's check if our expectation holds.
// Then
assert.strictEqual(actual, expected)
await sut.close()
We simply assert whether the actual answer is equal to the expected one. Then, we do not forget to close the system by
calling the close
method.
Let's assume, that we want to test our handlers for the Appeared and Disappeared Events. First, let's create the actual implementation:
@Service()
class HandlerService {
@ServiceAppeared()
async onAppeared(name: string) {
}
}
Then, as usual, we follow this up, by creating a test arrangement and a system under test:
const arrangement = await Chalupa
.builder()
.createServiceWithStrategy(HandlerService, IntegrationTestBuilderStrategy)
const sut = await arrangement.start()
Actually invoking the appropriate event is then as easy as:
await sut.serviceAppeared('name of the appeared service')
await sut.serviceDisappeared('name of the disappeared service')
When testing our service, situations may arise, when we want to assert on the state of some component bound inside the
context of the service. We already saw how to retrieve services and modules from the context using
the getServiceOrModule
method. The former, retrieving an arbitrary binding can be done via the getComponent
method,
as follows:
const arrangement = await Chalupa
.builder()
.createServiceWithStrategy(GreeterService, IntegrationTestBuilderStrategy)
const sut = await arrangement.start()
const greeter = sut.getComponent('Greeter Type Key')
Previously, we saw how to alter the context of the tested service by rebinding existing bindings. However, this is not the only area we need to control: configuration also plays a crucial role in the behavior of our service (through, for example, Configuration-dependent Bindings), and thus should be modifiable in tests.
Without explicit framework, support, we might think of the following two solutions:
- Overwriting
process.env
.- This is a great quick and dirty solution which gets the job done. However, modifying the global state can introduce subtle bugs (we forget to reset it, for example) as well as hinder our capability for parallel testing in the same process.
- Rebinding configuration classes.
- We can just use
arrangement.rebind()
and rebind the appropriate configuration class. While this works, it might be too late: by the timearrangement.rebind()
runs, theinject
functions have already executed. Hence, you cannot alter dynamic module bindings this way, for example.
- We can just use
Fortunately, Chalupa provides the OverrideConfig
plugin (see Plugins
and Extending Chalupa for more information on plugins) for this use case.
Let's assume, that we have the following configuration class:
@Configuration()
class PizzaConfig {
@Configurable({
doc: 'The data directory to save files into.',
format: 'string',
})
dataDirectory = '/data/pizza'
}
By default, the value of the dataDirectory
property is /data/pizza
, which can be overwritten by an appropriate
environment variable or file source. However, we want to set dataDirectory
to /not-pizza
without messing around with
those sources. To do so, we just use OverrideConfig
when creating our service:
const arrangement = await Chalupa
.builder()
.use(/* 1. */ OverrideConfig.builder()
/* 2. */
.add(PizzaConfig, {
/* 3. */
dataDirectory: '/not-pizza'
})
/* 4. */
.build()
)
.createServiceWithStrategy(PizzaService, IntegrationTestBuilderStrategy)
- We create an override builder by calling
OverrideConfig.builder()
. This call creates a new builder object on which we can set which configuration properties we want to override. - For each configuration class on which we want to override properties, we call the
add()
method. The first parameter of this call is the configuration class (PizzaConfig
, in this case), while the second parameter is an object of name-value pairs, where value is the new, fixed value. Note, that this object is strongly typed (using thekeyof
operator of TypeScript), so you can only use valid properties of the configuration class. - We specify the new value of the
dataDirectory
. - With
build()
we actually create the plugin instance which will be used.
The very same facilities can be used to override computed values as well. Given the following configuration class:
@Configuration()
class FileStoreConfig {
@Configurable({
doc: 'The environment the application is executing in.',
format: ['local', 'staging', 'production'],
})
env = 'production'
isLocalEnv() {
return this.env === 'local'
}
}
If we want to override the value of isLocalEnv
, we can write the following:
const arrangement = await Chalupa
.builder()
.use(OverrideConfig.builder()
.add(FileStoreConfig, {
isLocalEnv: true
})
.build()
)
.createServiceWithStrategy(FileStoreService, IntegrationTestBuilderStrategy)
Now, the isLocalEnv
function will always return true
.
As written in the Modules, modules are not executable by themselves: they need a containing or hosting service to operate. Even if that's the case, we still need to integration test them somehow. Fortunately, Chalupa provides facilities to test not only services, but individual modules as well.
Let's assume, that we have a module which provides a version method as follows:
@Module()
class VersionModule
()
{
private readonly
_version: string
constructor(@Inject('VERSION')
version: string
)
{
this._version = version
}
@ServiceMethod()
async
version()
:
Promise < string > {
return this._version
}
}
We can use the ModuleHost
class to create a containing or hosting service for this module for testing purposes:
import { ModuleHost } from '@chalupajs/test-framework'
const arrangement = await Chalupa
.builder()
.createServiceWithStrategy(ModuleHost.fromModule(VersionModule), IntegrationTestBuilderStrategy)
Then, we can use this arrangement to write tests the same way as in the case of services:
// Given
const expected = '1.0.0'
const sut = arrangement
.bind('VERSION', expected)
.start()
// When
const actual = await sut
.getServiceOrModule(VersionModule)
.version()
// Then
assert.strictEqual(actual, expected)
await sut.close()
If we want to customize the host service, then we can use the fromServiceOptions
method, which accepts an object with
the same properties as the @Service
decorator.
const host = ModuleHost.fromServiceOptions({
modules: [VersionModule]
})
const arrangement = await Chalupa
.builder()
.createServiceWithStrategy(host, IntegrationTestBuilderStrategy)
This one's tough :(