---
url: /intermediate/patterns-and-boundaries.md
description: >-
  One-job modules, service and controller shapes, and rules for dependency
  direction.
---

# Patterns and Boundaries

*Written: {{ $frontmatter.date }}*

## 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

* [Server Authority](../roblox-fundamentals/server-authority.md)
* [Modules and Require](../luau-basics/modules-and-require.md)
* [Remotes and Networking](../roblox-fundamentals/remotes-and-networking.md)

Samples use `--!strict` annotations throughout. Each annotation is explained where it first appears, and [Strict Typing](../strict-typing/modes.md) 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 ShopConfig
```

One 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](https://create.roblox.com/docs/en-us/projects/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 ShopService
```

One 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](https://create.roblox.com/docs/en-us/projects/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 ShopController
```

Only 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](https://create.roblox.com/docs/en-us/scripting/events/remote) 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 ShopMath
```

One 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](../luau-basics/modules-and-require.md). 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](https://github.com/UpliftGames/wally) instead of hand-rolled wiring, following the [ServerComm](https://sleitnick.github.io/RbxUtil/api/ServerComm) and [ClientComm](https://sleitnick.github.io/RbxUtil/api/ClientComm) pages. The [remote events](https://create.roblox.com/docs/en-us/scripting/events/remote) 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 ShopService
```

It 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 ShopController
```

It 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

1. Split a mixed module into a config leaf and a Service.
2. Convert a sibling require into a start-arg injection.
3. Add a frozen Shared helper used by both sides.

::: details Solution 1
**ModuleScript in ReplicatedStorage named RoundConfig**

```luau
--!strict
local RoundConfig = {
	roundSeconds = 120,
	maxPlayers = 8,
}
table.freeze(RoundConfig)
return RoundConfig
```

**ModuleScript 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 RoundService
```

Expected behavior: config stays frozen and the Service owns its running flag.
:::

::: details 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 GiftService
```

Expected behavior: the Service uses the injected grant and requires no sibling.
:::

::: details 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 CoinMath
```

**Script 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](./state-and-data.md).
