Skip to main content

How ServiceBag works

ServiceBag provides services and helps with game or plugin initialization.

tl;dr

ServiceBag is like a game in Roblox. You can retrieve services from it, and it will ensure the service exists and is initialized. This will bootstrap any other dependent dependencies.

Why understanding ServiceBag is important

Nevermore tries to be a collection of libraries that can be plugged together, and not exist as a set framework that forces specific design decisions. While there are certainly some design patterns these libraries will guide you to, you shouldn't necessarily feel forced to operate within these set of scenarios.

That being said, in order to use certain services, like CmdrService or permission service, you need to be familiar with ServiceBag.

If you're making a game with Nevermore, serviceBag solves a wide variety of problems with the lifecycle of the game, and is fundamental to the fast iteration cycle intended with Nevermore.

Many prebuilt systems depend upon ServiceBag and expect to be initialized through ServiceBag.

Is ServiceBag good?

ServiceBag supports multiple production games. ServiceBag allows for functionality that isn't otherwise available in traditional programming techniques in Roblox. More specifically:

  • Your games initialization can be controlled specifically
  • Recursive initialization (transient dependencies) will not cause refactoring requirements at higher level games. Lower-level packages can add additional dependencies without fear of breaking their downstream consumers.
  • Life cycle management is maintained in a standardized way
  • You can technically have multiple copies of your service running at once. This is useful for plugins and stuff.

While serviceBag isn't required to make a quality Roblox game, and may seem confusing at first, ServiceBag or an equivalent lifecycle management system and dependency injection system is a really good idea.

What ServiceBag tries to achieve

ServiceBag does service dependency injection and initialization. These words may be unfamiliar with you. Dependency injection is the process of retrieving dependencies instead of constructing them in an object. Lifecycle management is the process of managing the life of services, which often includes the game.

For the most part, ServiceBag is interested in the initialization of services within your game, since most services will not deconstruct. This allows for services that cross-depend upon each other, for example, if service A and service B both need to know about each other, serviceBag will allow for this to happen. A traditional module script will not allow for a circular dependency in the same way.

ServiceBag achieves circular dependency support by having a lifecycle hook system.

What is a service

A service is a singleton, that is, a module of which exactly one exists. This is oftentimes very useful, especially in de-duplicating behavior. Services are actually something you should be familiar with on Roblox, if you've been programming on Roblox for a while.

-- Workspace is an example of a service in Roblox
local workspace = game:GetService("Workspace")

It's useful to define our own services. A canonical service in Nevermore looks like this.

--[=[
A canonical service in Nevermore
@class ServiceName
]=]

local require = require(script.Parent.loader).load(script)

local Maid = require("Maid")

local ServiceName = {}
ServiceName.ServiceName = "ServiceName"

function ServiceName:Init(serviceBag)
assert(not self._serviceBag, "Already initialized")
self._serviceBag = assert(serviceBag, "No serviceBag")
self._maid = Maid.new()

-- External
self._serviceBag:GetService(require("OtherService"))
end

function ServiceName:Start()
print("Started")
end

function ServiceName:MyMethod()
print("Hello")
end

function ServiceName:Destroy()
self._maid:DoCleaning()
end

return ServiceName

Service LifeCycle methods

There are 3 methods in a service that are precoded in a ServiceBag. These are as follows

  • Init(serviceBag) - Initializes the service. If any more services need to be initialized then this should also get those services at this time.
  • Start() - Called when the game starts. Cannot yield. Starts actual behavior, including logic that depends on other services.
  • Destroy() - Cleans up the existing service

All three of these services are optional. However, if you want to have services bootstrapped that this service depends upon, then you should do this in Init

What happens on ServiceBag:Init()

When init happens, ServiceBag will called :Init() on any service that has been retrieved. If any of these services retrieve additional services then these will also be initialized and stored in the ServiceBag. Notably ServiceBag will not use the direct memory of the service, but instead create a new table and store the state in the ServiceBag itself.

local serviceBag = ServiceBag.new()
serviceBag:GetService(packages.MyModuleScript)

serviceBag:Init()
serviceBag:Start()
info

ServiceBag will not allow your service to yield. This is to prevent a service from delaying your entires game start. If you need to yield, do work in start or export your API calls as promises. See Cmdr for a good example of how this works.

Retrieving a service from inside of :Init() that service is guaranteed to be initialized. Services are started in the order they're initialized.

function MyService:Init(serviceBag)
self._myOtherService = serviceBag:GetService(require("MyOtherService"))

-- Services are guaranteed to be initialized if you retrieve them in an
-- init of another service, assuming that :Init() is done via ServiceBag.
self._myOtherService:Register(self)
end

When init is over, no more services can be added to the serviceBag.

What happens on ServiceBag:Start()

When Start happens the serviceBag will go through each of its services that have been initialized and attempt to call the :Start() method on it if it exists.

This is a good place to use other services that you may have needed as they are guaranteed to be initialized. However, you can also typically assume initialization is done in the :Init() method. However, sometimes you may assume initialization but no start.

What happens on ServiceBag:Destroy()

When :Destroy() is called, all services are destroyed. The serviceBag will call Destroy() on services if they offer it. This functionality is useful if you're initializing services during hoarcekat stories or unit tests.

How do I retrieve services

You retrieve a service by calling GetService. GetService takes in a table. If you pass it a module script, the service bag will require the module script and use the resulting definition as the service definition.

local serviceBag = ServiceBag.new()

local myService = serviceBag:GetService(packages.MyModuleScript)

serviceBag:Init()
serviceBag:Start()

As soon as you retrieve the service you should be able to call methods on it. However, the state of the service will be whatever it is before init or start. You may want to call :Init() or :Start() before using methods on the service.

Why can't you pass in arguments into :GetService()

Service configuration is not offered in the retrieval of :GetService() because inherently we don't want unstable or random behavior in our games. If we had arguments in ServiceBag then you better hope that your initialization order gets to configure the first service first. Otherwise, if another package adds a service in the future then you will have different behavior.

How do you configure a service instead of arguments?

Typically, you can configure a service by calling a method after :Init() is called, or after :Start() is called.

Should services have side effects when initialized or started?

Services should typically not have side effects when initialized or started.

Dependency injection

ServiceBag is also effectively a dependency injection system. In this system you can of course, inject services into other services.

For this reason, we inject the ServiceBag into the actual package itself.

-- Service bag injection
function CarCommandService:Init(serviceBag)
self._serviceBag = assert(serviceBag, "No serviceBag")

self._cmdrService = self._serviceBag:GetService(require("CmdrService"))
end

Dependency injection in objects

If you've got an object, it's typical you may need a service there

--[=[
@class MyClass
]=]

local require = require(script.Parent.loader).load(script)

local BaseObject = require("BaseObject")

local MyClass = setmetatable({}, BaseObject)
MyClass.ClassName = "MyClass"
MyClass.__index = MyClass

function MyClass.new(serviceBag)
local self = setmetatable(BaseObject.new(), MyClass)

self._serviceBag = assert(serviceBag, "No serviceBag")
self._cameraStackService = self._serviceBag:GetService(require("CameraStackService"))

return self
end

return MyClass

It's very common to pass or inject a service bag into the service

Dependency injection in binders

Binders explicitly support dependency injection. You can see that a binderProvider here retrieves a serviceBag (or any argument you want) and then the binder retrieves the extra argument.

return BinderProvider.new(script.Name, function(self, serviceBag)
-- ...
self:Add(Binder.new("Ragdoll", require("RagdollClient"), serviceBag))
-- ...
end)

Binders then will get the ServiceBag as the second argument.


function Ragdoll.new(humanoid, serviceBag)
local self = setmetatable(BaseObject.new(humanoid), Ragdoll)

self._serviceBag = assert(serviceBag, "No serviceBag")
-- Use services here.

return self
end

Memory management - ServiceBag will annotate stuff for you

ServiceBag will automatically annotate your service with a memory profile name so that it is easy to track down which part of your codebase is using memory. This fixes a standard issue with diagnosing memory in a single-script architecture.

Using ServiceBag with stuff that doesn't have access to ServiceBag

If you're working with legacy code, or external code, you may not want to pass an initialized ServiceBag around. This will typically make the code less testable, so take this with caution, but you can typically use a few helper methods to return fully initialized services instead of having to retrieve them through the servicebag.

local function getAnyModule(module)
if serviceBag:HasService(module) then
return serviceBag:GetService(module)
else
return module
end
end

It's preferably your systems interop with ServiceBag directly as ServiceBag provides more control, better testability, and more clarity on where things are coming from.