Skip to content

Commit 97398fd

Browse files
committed
docs: Add ServiceBag docs
1 parent e62b5ea commit 97398fd

File tree

2 files changed

+327
-0
lines changed

2 files changed

+327
-0
lines changed

.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ build
99
sourcemap.json
1010
dist
1111
*.tsbuildinfo
12+
.DS_Store
1213

1314
# Normally it's a good idea to commit this. However, in this mono-repo scenario with linking
1415
# it adds a ton of noise and no real gain. Also we have seen no gains from reproducing

docs/servicebag.md

+326
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,326 @@
1+
# How ServiceBag works
2+
3+
## tl;dr
4+
5+
ServiceBag is like a `game` in Roblox. You can retrieve services from it, and
6+
it will ensure the service exists and is initialized. This will bootstrap any
7+
other dependent dependencies.
8+
9+
## Why understanding ServiceBag is important
10+
11+
Nevermore tries to be a collection of libraries that can be plugged together,
12+
and not exist as a set framework that forces specific design decisions. While
13+
there are certainly some design patterns these libraries will guide you to,
14+
you shouldn't necessarily feel forced to operate within these set of
15+
scenarios.
16+
17+
That being said, in order to use certain services, like `CmdrService` or
18+
permission service, you need to be familiar with `ServiceBag`.
19+
20+
If you're making a game with Nevermore, serviceBag solves a wide variety
21+
of problems with the lifecycle of the game, and is fundamental to the fast
22+
iteration cycle intended with Nevermore.
23+
24+
Many prebuilt systems depend upon ServiceBag and expect to be initialized
25+
through ServiceBag.
26+
27+
## Is ServiceBag good?
28+
29+
ServiceBag supports multiple production games. ServiceBag allows for
30+
functionality that isn't otherwise available in traditional programming
31+
techniques in Roblox. More specifically:
32+
33+
* Your games initialization can be controlled specifically
34+
* Recursive initialization (transient dependencies) will not cause refactoring
35+
requirements at higher level games. Lower-level packages can add additional
36+
dependencies without fear of breaking their downstream consumers.
37+
* Life cycle management is maintained in a standardized way
38+
* You can technically have multiple copies of your service running at once. This
39+
is useful for plugins and stuff.
40+
41+
While serviceBag isn't required to make a quality Roblox game, and may seem
42+
confusing at first, ServiceBag or an equivalent lifecycle management system
43+
and dependency injection system is a really good idea.
44+
45+
## What ServiceBag tries to achieve
46+
47+
ServiceBag does service dependency injection and initialization. These words
48+
may be unfamiliar with you. Dependency injection is the process of retrieving
49+
dependencies instead of constructing them in an object. Lifecycle management is
50+
the process of managing the life of services, which often includes the game.
51+
52+
For the most part, ServiceBag is interested in the initialization of services
53+
within your game, since most services will not deconstruct. This allows for
54+
services that cross-depend upon each other, for example, if service A and
55+
service B both need to know about each other, serviceBag will allow for this
56+
to happen. A traditional module script will not allow for a circular dependency
57+
in the same way.
58+
59+
ServiceBag achieves circular dependency support by having a lifecycle hook
60+
system.
61+
62+
## What is a service
63+
64+
A service is a singleton, that is, a module of which exactly one exists. This
65+
is oftentimes very useful, especially in de-duplicating behavior. Services
66+
are actually something you should be familiar with on Roblox, if you've been
67+
programming on Roblox for a while.
68+
69+
```lua
70+
-- Workspace is an example of a service in Roblox
71+
local workspace = game:GetService("Workspace")
72+
```
73+
74+
It's useful to define our own services. A canonical service in Nevermore looks
75+
like this.
76+
77+
```lua
78+
--[=[
79+
A canonical service in Nevermore
80+
@class ServiceName
81+
]=]
82+
83+
local require = require(script.Parent.loader).load(script)
84+
85+
local Maid = require("Maid")
86+
87+
local ServiceName = {}
88+
ServiceName.ServiceName = "ServiceName"
89+
90+
function ServiceName:Init(serviceBag)
91+
assert(not self._serviceBag, "Already initialized")
92+
self._serviceBag = assert(serviceBag, "No serviceBag")
93+
self._maid = Maid.new()
94+
95+
-- External
96+
self._serviceBag:GetService(require("OtherService"))
97+
end
98+
99+
function ServiceName:Start()
100+
print("Started")
101+
end
102+
103+
function ServiceName:MyMethod()
104+
print("Hello")
105+
end
106+
107+
function ServiceName:Destroy()
108+
self._maid:DoCleaning()
109+
end
110+
111+
return ServiceName
112+
```
113+
114+
## Service LifeCycle methods
115+
116+
There are 3 methods in a service that are precoded in a `ServiceBag`. These
117+
are as follows
118+
119+
* `Init(serviceBag)` - Initializes the service. If any more services need to
120+
be initialized then this should also get those services at this time.
121+
* `Start()` - Called when the game starts. Cannot yield. Starts actual
122+
behavior, including logic that depends on other services.
123+
* `Destroy()` - Cleans up the existing service
124+
125+
All three of these services are optional. However, if you want to have
126+
services bootstrapped that this service depends upon, then you should
127+
do this in `Init`
128+
129+
### What happens on ServiceBag:Init()
130+
131+
When init happens, ServiceBag will called :Init() on any service that has been
132+
retrieved. If any of these services retrieve additional services then these
133+
will also be initialized and stored in the ServiceBag. Notably ServiceBag
134+
will not use the direct memory of the service, but instead create a new table
135+
and store the state in the ServiceBag itself.
136+
137+
138+
```lua
139+
local serviceBag = ServiceBag.new()
140+
serviceBag:GetService(packages.MyModuleScript)
141+
142+
serviceBag:Init()
143+
serviceBag:Start()
144+
```
145+
146+
:::info
147+
ServiceBag will not allow your service to yield. This is to prevent a service
148+
from delaying your entires game start. If you need to yield, do work in start
149+
or export your API calls as promises. See Cmdr for a good example of how this
150+
works.
151+
:::
152+
153+
Retrieving a service from inside of :Init() that service is guaranteed to be
154+
initialized. Services are started in the order they're initialized.
155+
156+
```lua
157+
function MyService:Init(serviceBag)
158+
self._myOtherService = serviceBag:GetService(require("MyOtherService"))
159+
160+
-- Services are guaranteed to be initialized if you retrieve them in an
161+
-- init of another service, assuming that :Init() is done via ServiceBag.
162+
self._myOtherService:Register(self)
163+
end
164+
```
165+
166+
167+
When init is over, no more services can be added to the serviceBag.
168+
169+
### What happens on ServiceBag:Start()
170+
171+
When Start happens the serviceBag will go through each of its services
172+
that have been initialized and attempt to call the :Start() method on it
173+
if it exists.
174+
175+
This is a good place to use other services that you may have needed as they
176+
are guaranteed to be initialized. However, you can also typically assume
177+
initialization is done in the :Init() method. However, sometimes you may
178+
assume initialization but no start.
179+
180+
### What happens on ServiceBag:Destroy()
181+
182+
When :Destroy() is called, all services are destroyed. The serviceBag will
183+
call `Destroy()` on services if they offer it. This functionality is useful
184+
if you're initializing services during hoarcekat stories or unit tests.
185+
186+
187+
## How do I retrieve services
188+
189+
You retrieve a service by calling `GetService`. `GetService` takes in a table.
190+
If you pass it a module script, the service bag will require the module
191+
script and use the resulting definition as the service definition.
192+
193+
```lua
194+
local serviceBag = ServiceBag.new()
195+
196+
local myService = serviceBag:GetService(packages.MyModuleScript)
197+
198+
serviceBag:Init()
199+
serviceBag:Start()
200+
```
201+
202+
As soon as you retrieve the service you should be able to call methods on it.
203+
However, the state of the service will be whatever it is before init or start.
204+
You may want to call :Init() or :Start() before using methods on the service.
205+
206+
207+
## Why can't you pass in arguments into :GetService()
208+
209+
Service configuration is not offered in the retrieval of :GetService() because
210+
inherently we don't want unstable or random behavior in our games. If we had
211+
arguments in ServiceBag then you better hope that your initialization order
212+
gets to configure the first service first. Otherwise, if another package adds
213+
a service in the future then you will have different behavior.
214+
215+
### How do you configure a service instead of arguments?
216+
217+
Typically, you can configure a service by calling a method after :Init() is
218+
called, or after :Start() is called.
219+
220+
### Should services have side effects when initialized or started?
221+
222+
Services should typically not have side effects when initialized or started.
223+
224+
## Dependency injection
225+
226+
ServiceBag is also effectively a dependency injection system. In this system
227+
you can of course, inject services into other services.
228+
229+
For this reason, we inject the ServiceBag into the actual package itself.
230+
231+
```lua
232+
-- Service bag injection
233+
function CarCommandService:Init(serviceBag)
234+
self._serviceBag = assert(serviceBag, "No serviceBag")
235+
236+
self._cmdrService = self._serviceBag:GetService(require("CmdrService"))
237+
end
238+
```
239+
240+
### Dependency injection in objects
241+
242+
If you've got an object, it's typical you may need a service there
243+
244+
```lua
245+
--[=[
246+
@class MyClass
247+
]=]
248+
249+
local require = require(script.Parent.loader).load(script)
250+
251+
local BaseObject = require("BaseObject")
252+
253+
local MyClass = setmetatable({}, BaseObject)
254+
MyClass.ClassName = "MyClass"
255+
MyClass.__index = MyClass
256+
257+
function MyClass.new(serviceBag)
258+
local self = setmetatable(BaseObject.new(), MyClass)
259+
260+
self._serviceBag = assert(serviceBag, "No serviceBag")
261+
self._cameraStackService = self._serviceBag:GetService(require("CameraStackService"))
262+
263+
return self
264+
end
265+
266+
return MyClass
267+
```
268+
269+
It's very common to pass or inject a service bag into the service
270+
271+
### Dependency injection in binders
272+
273+
Binders explicitly support dependency injection. You can see that a
274+
binderProvider here retrieves a serviceBag (or any argument you want)
275+
and then the binder retrieves the extra argument.
276+
277+
```lua
278+
return BinderProvider.new(script.Name, function(self, serviceBag)
279+
-- ...
280+
self:Add(Binder.new("Ragdoll", require("RagdollClient"), serviceBag))
281+
-- ...
282+
end)
283+
```
284+
285+
Binders then will get the `ServiceBag` as the second argument.
286+
287+
```lua
288+
289+
function Ragdoll.new(humanoid, serviceBag)
290+
local self = setmetatable(BaseObject.new(humanoid), Ragdoll)
291+
292+
self._serviceBag = assert(serviceBag, "No serviceBag")
293+
-- Use services here.
294+
295+
return self
296+
end
297+
```
298+
299+
## Memory management - ServiceBag will annotate stuff for you
300+
301+
ServiceBag will automatically annotate your service with a memory profile name
302+
so that it is easy to track down which part of your codebase is using memory.
303+
This fixes a standard issue with diagnosing memory in a single-script
304+
architecture.
305+
306+
## Using ServiceBag with stuff that doesn't have access to ServiceBag
307+
308+
If you're working with legacy code, or external code, you may not want
309+
to pass an initialized ServiceBag around. This will typically make the code
310+
less testable, so take this with caution, but you can typically use a few
311+
helper methods to return fully initialized services instead of having to
312+
retrieve them through the servicebag.
313+
314+
```lua
315+
local function getAnyModule(module)
316+
if serviceBag:HasService(module) then
317+
return serviceBag:GetService(module)
318+
else
319+
return module
320+
end
321+
end
322+
```
323+
324+
It's preferably your systems interop with ServiceBag directly as ServiceBag
325+
provides more control, better testability, and more clarity on where things
326+
are coming from.

0 commit comments

Comments
 (0)