Appearance
Patterns and Boundaries
Written: 2026-10-14
Learning objectives
- You can give each module one job.
- You can shape a server Service with start.
- You can shape a client Controller with start.
- You can keep Shared modules frozen and pure.
- You can enforce dependency direction rules.
- You can wire modules through the four legal channels.
Prerequisites
Samples use --!strict annotations throughout. Each annotation is explained where it first appears, and Strict Typing teaches the full system later.
One module does one job
Each feature gets one home. A shop, a round timer, and a coin grant become three modules. Shop pricing never mixes with round timing in one table.
A focused module names its domain. The name tells the job. The job tests without starting the whole game. A bug gets fixed in one place.
ModuleScript in ReplicatedStorage named ShopConfig
luau
--!strict
export type ShopItem = {
Id: string,
Price: number,
MaxStack: number,
}
local ShopConfig: { Items: { ShopItem } } = {
Items = {
{ Id = "Apple", Price = 5, MaxStack = 5 },
{ Id = "Bread", Price = 3, MaxStack = 10 },
},
}
table.freeze(ShopConfig)
return ShopConfigOne frozen table holds the data. Each item carries an id, a price, and a stack limit. The table freezes so no system retunes it by accident. Expected behavior: every requirer reads the same two items and cannot edit them.
Services own the server
Each server feature is a singleton table with a start entry. It accepts a janitor handle and a deps table from the bootstrap. It never requires a sibling Service. It reads shared config and uses injected deps only.
The server authority techniques guide covers prediction and rollback for smooth server-decided movement. The Service follows that rule. It validates intents and owns outcomes.
ModuleScript in ServerScriptService named ShopService
luau
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ShopConfig = require(ReplicatedStorage:WaitForChild("ShopConfig"))
local ShopService = {}
local balances: { [number]: number } = {}
local function findPrice(itemId: string): number?
for _, item in ShopConfig.Items do
if item.Id == itemId then
return item.Price
end
end
return nil
end
function ShopService.getBalance(player: Player): number
local owned = balances[player.UserId]
if owned == nil then
return 0
end
return owned
end
function ShopService.buy(player: Player, itemId: unknown, count: unknown): boolean
if typeof(itemId) ~= "string" then
return false
end
if typeof(count) ~= "number" then
return false
end
if not math.isfinite(count) then
return false
end
if count < 1 or count > 5 then
return false
end
local price = findPrice(itemId)
if price == nil then
return false
end
local owned = ShopService.getBalance(player)
if owned < price * count then
return false
end
balances[player.UserId] = owned - price * count
return true
end
function ShopService.start(janitor: any, deps: { grantCoins: (Player, number) -> () }): ()
local grant = deps.grantCoins
if grant == nil then
return
end
balances[100] = 50
janitor:Add(function(): ()
table.clear(balances)
end)
end
return ShopServiceOne named table holds the feature. Balances stay private. Type, finite range, catalog match, and funds all validate before spending. grantCoins arrives through deps instead of a sibling require. Cleanup registers through the injected janitor. Expected behavior: valid buys spend funds and return true, while bad shapes and poor buyers return false.
Controllers own the client
Each client feature is a singleton table with a parameterless start. It renders state and fires intents. It never decides outcomes. It never requires a Service or another Controller.
The client-server model gives the client display and input only. The Controller follows that split. It sends an intent and waits for the server answer.
ModuleScript in StarterPlayerScripts named ShopController
luau
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ShopConfig = require(ReplicatedStorage:WaitForChild("ShopConfig"))
local ShopController = {}
local selectedId: string = "Apple"
local function pickFirstItem(): ()
local first = ShopConfig.Items[1]
if first == nil then
return
end
selectedId = first.Id
end
function ShopController.getSelected(): string
return selectedId
end
function ShopController.start(): ()
pickFirstItem()
local basket = ReplicatedStorage:WaitForChild("BuyItem") :: RemoteEvent
basket:FireServer(selectedId, 1)
end
return ShopControllerOnly shared config is required. The first catalog id is picked. One intent fires with an id and a count. A getter exposes the pick for UI code. Expected behavior: the client sends Apple and 1 once at start and never grants itself anything.
Shared stays frozen and pure
Shared stays free of side effects. Config, ids, and types live there. Every returned table freezes. No services are fetched for effects, and no events connect at require time.
Shared lets both sides reuse one rule. Price math goes in Shared when the client preview and the server check must match. Authority stays in the Service. The remote events guide reminds you that tables cross as copies, so both sides need the same frozen source.
ModuleScript in ReplicatedStorage named ShopMath
luau
--!strict
local ShopMath = {}
function ShopMath.total(price: number, count: number): number
if not math.isfinite(price) then
return 0
end
if not math.isfinite(count) then
return 0
end
if count < 0 then
return 0
end
return math.floor(price * count)
end
return ShopMathOne pure function does the work. Non-finite inputs get rejected. The product floors. Both the Service and the Controller can require this module without copying logic. Expected behavior: total(5, 2) returns 10 on either side.
Dependencies flow one way
Dangerous requires are forbidden by construction. Services never require Services or Controllers. Controllers never require Services or each other. Shared never requires server or client modules.
Arrows point down only. The bootstrap requires Services. Services require Shared. Controllers require Shared. Nothing points sideways or up.
When two features need one value, extract a third Shared module. This leaf pattern comes from Modules and Require. It breaks a would-be cycle before it starts.
Script in ServerScriptService named ServerMain
luau
--!strict
local ServerScriptService = game:GetService("ServerScriptService")
local DataService = require(ServerScriptService:WaitForChild("DataService"))
local ShopService = require(ServerScriptService:WaitForChild("ShopService"))
local cleanupList: { () -> () } = {}
local janitor = {}
function janitor.Add(_self: any, fn: () -> ()): ()
table.insert(cleanupList, fn)
end
local sharedGrant = DataService.grantCoins
if sharedGrant == nil then
return
end
ShopService.start(janitor, { grantCoins = sharedGrant })
print(#cleanupList)Each Service is required once at the top. One janitor table is built in the bootstrap. grantCoins passes as a start arg. No Service requires its sibling. Expected behavior: Output prints 1 after the shop registers its cleanup.
The four legal channels
You connect features through four channels only. You pick the narrowest channel that fits.
First, inject through start args. Pass functions and tables the bootstrap resolves. Use this for server-to-server sharing like a coin grant.
Second, share frozen config and pure helpers. Require one Shared module from both sides. Use this for prices, ids, and math.
Third, signal on the same side. Use a BindableEvent or a Signal table for module-to-module notes on one side only. Keep Bindables same-side. Never send a Bindable across the client server boundary.
Fourth, cross the boundary with remotes. Fire a RemoteEvent intent from the client and validate it in a Service. Production code can own those remotes with the Comm package from Wally instead of hand-rolled wiring, following the ServerComm and ClientComm pages. The remote events guide defines the FireServer and OnServerEvent pair used here.
Script in ServerScriptService
luau
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerScriptService = game:GetService("ServerScriptService")
local ShopService = require(ServerScriptService:WaitForChild("ShopService"))
local basket = ReplicatedStorage:WaitForChild("BuyItem") :: RemoteEvent
basket.OnServerEvent:Connect(function(player: Player, itemId: unknown, count: unknown): ()
local bought = ShopService.buy(player, itemId, count)
if not bought then
return
end
print(player.Name)
end)Create a RemoteEvent named BuyItem in ReplicatedStorage. Wait for the shared remote. Delegate to ShopService.buy. Return silently on failure. Expected behavior: valid buys print the buyer name, while invalid shapes print nothing.
Requires stay static
Write every require at the top with a literal path. Sort the block by local name. Resolve the container with WaitForChild first. Never compute a path at runtime, and never require inside a loop or a branch.
Static requires load fast and they survive refactors. Dynamic paths hide cycles. A require inside a loop repeats work and it masks the true dependency list.
LocalScript in StarterPlayerScripts
luau
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ShopConfig = require(ReplicatedStorage:WaitForChild("ShopConfig"))
local ShopMath = require(ReplicatedStorage:WaitForChild("ShopMath"))
local first = ShopConfig.Items[1]
if first == nil then
return
end
print(ShopMath.total(first.Price, 2))ReplicatedStorage is fetched once. Two Shared modules are required in one top block. The catalog read gets a guard. Expected behavior: Output prints 10 for two apples.
Common mistakes
Mistake 1: a Service requires its sibling.
Wrong example below. It reaches sideways for coins.
ModuleScript in ServerScriptService named ShopService
luau
--!strict
local ServerScriptService = game:GetService("ServerScriptService")
local CoinService = require(ServerScriptService:WaitForChild("CoinService"))
local ShopService = {}
function ShopService.buy(player: Player): boolean
if CoinService == nil then
return false
end
return true
end
function ShopService.start(_janitor: any, _deps: {}): ()
print("started")
end
return ShopServiceIt couples two features. Startup order now matters and tests need both modules. The bootstrap cannot swap a fake grant. Fix it by accepting grantCoins through start args.
Mistake 2: a Controller requires another Controller.
Wrong example below. It pulls UI state sideways.
ModuleScript in StarterPlayerScripts named ShopController
luau
--!strict
local CartController = require(script.Parent:WaitForChild("CartController"))
local ShopController = {}
function ShopController.start(): ()
CartController.refresh()
end
return ShopControllerIt ties two client features together. One change breaks both. Fix it by sharing frozen config or by signaling on the same side through the bootstrap, never by direct require.
Mistake 3: a dynamic require hides the dependency.
Wrong example below. It builds the path at runtime.
Script in ServerScriptService
luau
--!strict
local ServerScriptService = game:GetService("ServerScriptService")
local wanted = "ShopService"
local ShopService = require(ServerScriptService:WaitForChild(wanted))
print(ShopService.getBalance(game.Players:GetPlayers()[1]))It defeats search and sorting. Reviewers cannot see the edge. Fix it with a literal WaitForChild("ShopService") in the top require block.
Exercises
- Split a mixed module into a config leaf and a Service.
- Convert a sibling require into a start-arg injection.
- Add a frozen Shared helper used by both sides.
Solution 1
ModuleScript in ReplicatedStorage named RoundConfig
luau
--!strict
local RoundConfig = {
roundSeconds = 120,
maxPlayers = 8,
}
table.freeze(RoundConfig)
return RoundConfigModuleScript in ServerScriptService named RoundService
luau
--!strict
local RoundService = {}
local running: boolean = false
function RoundService.isRunning(): boolean
return running
end
function RoundService.start(_janitor: any, _deps: {}): ()
running = true
end
return RoundServiceExpected behavior: config stays frozen and the Service owns its running flag.
Solution 2
ModuleScript in ServerScriptService named GiftService
luau
--!strict
local GiftService = {}
function GiftService.start(_janitor: any, deps: { grantCoins: (Player, number) -> () }): ()
local grant = deps.grantCoins
if grant == nil then
return
end
print("ready")
end
return GiftServiceExpected behavior: the Service uses the injected grant and requires no sibling.
Solution 3
ModuleScript in ReplicatedStorage named CoinMath
luau
--!strict
local CoinMath = {}
function CoinMath.fee(amount: number): number
if not math.isfinite(amount) then
return 0
end
if amount < 0 then
return 0
end
return math.floor(amount * 0.05)
end
return CoinMathScript in ServerScriptService
luau
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local CoinMath = require(ReplicatedStorage:WaitForChild("CoinMath"))
print(CoinMath.fee(100))Expected behavior: Output prints 5 on either side from one shared helper.
Summary
Keep one job per module. Shape Services with start(janitor, deps) and Controllers with start(). Freeze Shared and keep it pure. Forbid sideways requires. Wire through start args, Shared, same-side signals, and remotes. Require statically at the top. Next, model data in State and Data.