Appearance
Architecture
Written: 2026-10-14
Learning objectives
- You can state why Services, Controllers, Shared, and Data exist as separate boundaries.
- You can place one domain in one module and wire it through a bootstrap.
- You can route all persistence through DataService and all networking through Comm.
- You can order startup so DataService runs first and Controllers never block.
- You can spot a boundary break and fix it before it ships.
Prerequisites
Why boundaries exist
Boundaries exist because every feature pulls in a different direction. Gameplay logic wants server truth. Input wants fast client feedback. Tuning wants one shared source. Saves want one safe owner. Without walls, those needs mix in one file. Then one change breaks three systems.
The server authority model gives you the core rule. The server decides outcomes. The client sends intents and renders results. The authority techniques guide shows the prediction and rollback techniques that keep that split smooth at scale. Your folder split mirrors that engine split.
Trading a little file count buys safety. A flat tree where any script touches anything feels fast for one week. Then prices cannot change without breaking saves, and remotes cannot be added without hunting every writer. When one edit forces two unrelated fixes, split.
Services own authority
A Service lives on the server and owns one gameplay domain. It decides outcomes, validates intents, and writes authoritative state. It never trusts display values.
Shape every Service the same way. Open with --!strict. Fetch each Engine service once. Build a named local table. Expose one start entry. Return the table last.
ModuleScript in ServerScriptService named CoinService
luau
--!strict
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerScriptService = game:GetService("ServerScriptService")
local ServerPackages = ServerScriptService:WaitForChild("ServerPackages")
local Packages = ReplicatedStorage:WaitForChild("Packages")
local Comm = require(Packages:WaitForChild("Comm")) :: any
local Janitor = require(Packages:WaitForChild("Janitor")) :: any
local DataService = require(ServerScriptService:WaitForChild("DataService"))
local CoinService = {}
CoinService.BonusPerStreak = 5
function CoinService.start(janitor: any, serverComm: any): ()
local grantSignal = serverComm:CreateSignal("GrantCoins") :: any
if grantSignal == nil then
return
end
local connection = grantSignal:Connect(function(player: Player, count: unknown): ()
if typeof(count) ~= "number" then
return
end
if not math.isfinite(count) then
return
end
if count < 1 or count > 5 then
return
end
local character = player.Character
if character == nil then
return
end
local humanoid = character:FindFirstChildOfClass("Humanoid")
if humanoid == nil then
return
end
if humanoid.Health <= 0 then
return
end
local granted = DataService.addCoins(player, math.floor(count))
if not granted then
return
end
grantSignal:Fire(player, DataService.getCoins(player))
end)
janitor:Add(connection)
Players.PlayerRemoving:Connect(function(leaving: Player): ()
DataService.flush(leaving)
end)
end
return CoinServiceWait for the shared Packages container first, then require each child through it. Use :: any only because these Wally packages ship without Luau types. The Comm calls CreateSignal, Connect, and Fire per the ServerComm and RemoteSignal pages. The Janitor call Add per the Janitor docs tracks the connection for cleanup.
It wires state first, resolves DataService before connecting remotes, connects the grant intent second, and exposes readiness last. Expected behavior: valid counts between one and five add coins through DataService and echo the new total to that player only, while bad types, NaN, dead characters, and out-of-range counts return silently.
Callers cannot reach into CoinService state directly. They go through DataService accessors or Comm signals. Direct cross-require between Services dies on cycles and hidden order bugs. The split is right when coin math rewrites without touching shop code.
Controllers own intent
A Controller lives on the client. It renders state, captures input, and fires intent signals. It never decides outcomes or writes saves or economy state.
Keep Controllers thin on purpose. Thin means one screen deletes and rewrites without touching the server. The client-server boundary guide states the same split. The client proposes. The server disposes.
LocalScript in StarterPlayerScripts named CoinController
luau
--!strict
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Packages = ReplicatedStorage:WaitForChild("Packages")
local Comm = require(Packages:WaitForChild("Comm")) :: any
local player = Players.LocalPlayer
if player == nil then
return
end
local gui = player:WaitForChild("PlayerGui")
local screen = gui:WaitForChild("CoinScreen")
local total = screen:WaitForChild("Total") :: TextLabel
local collect = screen:WaitForChild("Collect") :: TextButton
local folder = ReplicatedStorage:WaitForChild("CoinComm")
local ClientComm = Comm.ClientComm :: any
local clientComm = ClientComm.new(folder, false, "CoinComm") :: any
local remote = clientComm:BuildObject() :: any
if remote == nil then
return
end
local grantSignal = remote.GrantCoins :: any
if grantSignal == nil then
return
end
local showConnection = grantSignal:Connect(function(nextTotal: unknown): ()
if typeof(nextTotal) ~= "number" then
return
end
if not math.isfinite(nextTotal) then
return
end
total.Text = tostring(math.floor(nextTotal))
end)
if showConnection == nil then
return
end
collect.Activated:Connect(function(): ()
grantSignal:Fire(1)
end)The Comm client calls ClientComm.new, BuildObject, Connect, and Fire per the ClientComm and RemoteSignal pages. Guard every step. Check the player exists. Check each remote object exists. Narrow the server echo from unknown to a finite number before display.
It captures input only, sends intent 1, and renders the server echo. Expected behavior: each press sends one grant intent, and the label updates only when the server replies with a finite total.
Failure story: a Controller writes coins directly. A leaderstats value is set on the client and trusted. An exploiter sets it to nine million. The shop reads the display value. That gives free items. The security tactics guide names this exact trap. Never trust the client. Fix it by moving the write into a Service and treating the label as paint.
Shared stays pure
Shared holds config, ids, types, and constants. It runs on both sides, does zero I/O on require, fetches no services with side effects, and connects nothing. Freeze the return so no caller retunes the game by accident.
ModuleScript in ReplicatedStorage named ShopConfig
luau
--!strict
export type ShopItem = {
id: string,
price: number,
maxStack: number,
}
local ShopConfig: { [string]: ShopItem } = {
Apple = { id = "Apple", price = 5, maxStack = 5 },
Bread = { id = "Bread", price = 3, maxStack = 10 },
}
table.freeze(ShopConfig)
return ShopConfigIt builds one dictionary and freezes it on the return path. Expected behavior: server and client require the same prices, and any write attempt errors instead of corrupting tuning.
Teams copy a price into a Service to avoid a new file. Then client and server drift. One Shared module per concept avoids that. When two modules need the same constant, extract it into Shared instead of copying it.
Keep one behavior per function in Shared helpers. Annotate every public boundary. Use unknown plus narrowing for untrusted lookups, not any. Let inference handle trivial locals.
DataService owns persistence
Only DataService touches saves. No other Service requires a persistence package. No Controller touches saves. No Shared module touches saves. This single-owner rule prevents races, double writes, and session steals.
The data stores guide gives the mechanics. Wrap every cloud call in pcall. Use GetAsync, SetAsync, and UpdateAsync with explicit failure paths. ProfileStore adds session locking on top of that pattern. Its session calls StartSessionAsync, Reconcile, and EndSession are conceptual here, verified against the ProfileStore tutorial. Repo convention uses Player_ key prefix; Tutorial keys are bare {player.UserId}. Verify them against the package source before shipping.
ModuleScript in ServerScriptService named DataService
luau
--!strict
local DataStoreService = game:GetService("DataStoreService")
local Players = game:GetService("Players")
local ServerScriptService = game:GetService("ServerScriptService")
local ServerPackages = ServerScriptService:WaitForChild("ServerPackages")
local ProfileStore = require(ServerPackages:WaitForChild("ProfileStore")) :: any
local DataService = {}
local coinsStore = ProfileStore.New("PlayerCoins_1", { Coins = 0 }) :: any
local sessions: { [number]: any } = {}
function DataService.start(): ()
Players.PlayerAdded:Connect(function(player: Player): ()
local session = coinsStore:StartSessionAsync(`Player_{player.UserId}`, {
Cancel = function(): boolean
return player.Parent ~= Players
end,
}) :: any
if session == nil then
player:Kick("Data failed to load")
return
end
session:Reconcile()
sessions[player.UserId] = session
end)
Players.PlayerRemoving:Connect(function(player: Player): ()
DataService.flush(player)
end)
end
function DataService.getCoins(player: Player): number
local session = sessions[player.UserId]
if session == nil then
return 0
end
local data = session.Data :: any
if typeof(data) ~= "table" then
return 0
end
local coins = (data :: any).Coins
if typeof(coins) ~= "number" then
return 0
end
if not math.isfinite(coins) then
return 0
end
return math.floor(coins)
end
function DataService.addCoins(player: Player, amount: number): boolean
if not math.isfinite(amount) then
return false
end
if amount < 1 or amount > 5 then
return false
end
local session = sessions[player.UserId]
if session == nil then
return false
end
local data = session.Data :: any
if typeof(data) ~= "table" then
return false
end
local current = (data :: any).Coins
if typeof(current) ~= "number" then
return false
end
if not math.isfinite(current) then
return false
end
(data :: any).Coins = math.floor(current) + math.floor(amount)
return true
end
function DataService.flush(player: Player): ()
local session = sessions[player.UserId]
if session == nil then
return
end
sessions[player.UserId] = nil
local done = false
local ok, _ = pcall(function(): ()
session:EndSession()
done = true
end)
if not ok then
done = false
end
if not done then
return
end
end
local FallbackStore = DataStoreService:GetDataStore("PlayerCoinsFallback")
function DataService.readFallback(userId: number): number
local done, saved = pcall(function()
return FallbackStore:GetAsync("Player_" .. tostring(userId))
end)
if not done then
return 0
end
if typeof(saved) ~= "number" then
return 0
end
if not math.isfinite(saved) then
return 0
end
return math.floor(saved)
end
return DataServiceScope keys with a Player_ prefix. Reconcile missing keys on load. Expose getCoins and addCoins through injected accessors. Other Services never see raw sessions. Expected behavior: joining players load one session each, coin reads return finite floors, bad sessions kick safely, and leaving players flush once.
Failure story: a second Service requires ProfileStore directly. Two owners open two sessions for one player. One session steals the lock. The loser retries and overwrites. Players lose purchases. Fix it by deleting the second require and injecting DataService through start args.
Dependency direction
Allow exactly four channels across modules: bootstrap-injected start args, Shared modules, same-side Signal or Bindable events, and Comm remotes across the boundary. Forbid everything else.
Services never require Services. Controllers never require Services or other Controllers. Shared never requires Services or Controllers. Data flows one way. Bootstrap resolves state. Services consume it. Controllers render it.
Failure story: a ShopService requires CoinService to read balances. CoinService later requires ShopService to check prices. Luau raises a recursive require at startup. Both systems stay dead. Fix it by extracting prices into ShopConfig in Shared and injecting a getCoins function through start args.
Failure story: a Controller requires a Service ModuleScript from ReplicatedStorage. It works in Studio because both sides see the file. It leaks server logic to exploiters. They decompile prices and drop rates. The client-server boundary guide warns to keep secrets server-side. Fix it by moving the Service into ServerScriptService and exposing only Comm intents.
Networking belongs to Comm
Use Comm for every cross-boundary message. The server binds. The client invokes. Never hand-roll RemoteEvent wiring when Comm is installed. Hand wiring drifts. Names mismatch. Cleanup leaks. Comm keeps one folder, one build, and one destroy path.
Server calls BindFunction and CreateSignal per the ServerComm page inside Services only. Client calls GetFunction and GetSignal or BuildObject per the ClientComm page inside Controllers only. Validate every inbound payload with layered checks. The remote events guide defines the underlying one-way and two-way shapes that Comm wraps.
A new dependency means learning its folder layout and accepting its update churn. Twenty hand-made remotes mean twenty spellings of validation. Keep Comm when remote count passes three. Keep it below that too, because the fourth remote always arrives next week.
Bootstrap wiring and start order
Bootstraps hold zero game logic. They resolve order, inject dependencies, and start systems. Keep ServerMain and ClientMain small so startup stays readable.
Order on the server: DataService first, then remaining Services in dependency order, then Comm bindings, then readiness. Order on the client: build ClientComm first, then start Controllers without yielding forever. The authority techniques guide applies prediction and rollback to keep simulated movement smooth under the same server-decides rule.
Script in ServerScriptService named ServerMain
luau
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerScriptService = game:GetService("ServerScriptService")
local Packages = ReplicatedStorage:WaitForChild("Packages")
local Comm = require(Packages:WaitForChild("Comm")) :: any
local Janitor = require(Packages:WaitForChild("Janitor")) :: any
local DataService = require(ServerScriptService:WaitForChild("DataService"))
local CoinService = require(ServerScriptService:WaitForChild("CoinService"))
local ShopService = require(ServerScriptService:WaitForChild("ShopService"))
DataService.start()
local janitor = Janitor.new() :: any
local ServerComm = Comm.ServerComm :: any
local folder = Instance.new("Folder")
folder.Name = "CoinComm"
folder.Parent = ReplicatedStorage
local serverComm = ServerComm.new(folder, "CoinComm") :: any
if serverComm == nil then
return
end
CoinService.start(janitor, serverComm)
ShopService.start(janitor, serverComm, DataService)Startup starts DataService before anyone reads coins. Create one Janitor at the top. Create one ServerComm folder with Parent last. Inject the Janitor and Comm into each Service. Expected behavior: joins load data first, coin and shop handlers bind after, and all connections die with one Janitor destroy on shutdown.
LocalScript in StarterPlayerScripts named ClientMain
luau
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local StarterPlayer = game:GetService("StarterPlayer")
local CoinController = require(StarterPlayer:WaitForChild("StarterPlayerScripts"):WaitForChild("CoinController"))
local ShopController = require(StarterPlayer:WaitForChild("StarterPlayerScripts"):WaitForChild("ShopController"))
CoinController.start()
ShopController.start()It starts each Controller without waiting and never yields forever here. Expected behavior: both screens bind instantly and render when server echoes arrive.
Change startup when a Service reads data before DataService finishes. Zeros appear on join, then correct values one second later. Fix it by moving the read after DataService readiness or by queuing the first grant until the session exists.
One domain per module
One feature owns one file. One CoinService grants coins. One ShopService sells items. One ShopConfig holds prices. Never combine shop and coins in one module. Never split coins across two Services, except through private helpers owned by that one Service.
Cap functions near thirty lines. Pass tuning through start args or Shared config. Delete dead code instead of commenting it out. Keep Instance.new configure-first with Parent last.
Split a module when its description uses the word and. Coin and shop, save and chat, or input and economy signal a split. Merge when two files only call each other and share one caller. Then the boundary adds cost without safety.
Common mistakes
Mistake 1: letting a Controller write data.
This wrong example writes leaderstats on the client.
LocalScript in StarterPlayerScripts
luau
--!strict
local Players = game:GetService("Players")
local player = Players.LocalPlayer
if player == nil then
return
end
local stats = player:WaitForChild("leaderstats")
local coins = stats:WaitForChild("Coins") :: IntValue
coins.Value += 100It fails because the server never approved the grant. Exploiters replay it at will. The security tactics guide tells you to treat every client write as hostile. Fix it by firing an intent and letting CoinService add through DataService.
Mistake 2: letting two Services own saves.
This wrong example requires ProfileStore outside DataService.
ModuleScript in ServerScriptService named ShopService
luau
--!strict
local ServerScriptService = game:GetService("ServerScriptService")
local ServerPackages = ServerScriptService:WaitForChild("ServerPackages")
local ProfileStore = require(ServerPackages:WaitForChild("ProfileStore")) :: any
local ShopService = {}
function ShopService.start(): ()
local store = ProfileStore.New("Shop_1", { Owned = {} }) :: any
if store == nil then
return
end
end
return ShopServiceIt fails because two stores open two sessions for one player. Locks fight. Writes clobber. Fix it by deleting the ProfileStore require and injecting DataService accessors through start. Confirm ProfileStore session calls in the ProfileStore API before wiring saves.
Mistake 3: requiring across the boundary.
This wrong example requires a server file from the client.
LocalScript in StarterPlayerScripts
luau
--!strict
local ServerScriptService = game:GetService("ServerScriptService")
local CoinService = require(ServerScriptService:WaitForChild("CoinService"))
CoinService.start(nil, nil)It fails because the client cannot see ServerScriptService at runtime. Moving the file to ReplicatedStorage to make it visible also leaks server logic. Fix it with Comm intents. The server binds with CreateSignal per the ServerComm page. The client fires with Fire per the RemoteSignal page. Neither side requires the other.
Exercises
- Split a combined module into one Service plus one Shared config.
- Move a direct Service-to-Service require into bootstrap injection.
- Wire DataService first and prove a join reads correct coins.
Solution 1
ModuleScript in ReplicatedStorage named RewardConfig
luau
--!strict
local RewardConfig = {
daily = 50,
streakBonus = 5,
maxClaim = 1,
}
table.freeze(RewardConfig)
return RewardConfigModuleScript in ServerScriptService named RewardService
luau
--!strict
local ServerScriptService = game:GetService("ServerScriptService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RewardConfig = require(ReplicatedStorage:WaitForChild("RewardConfig"))
local DataService = require(ServerScriptService:WaitForChild("DataService"))
local RewardService = {}
function RewardService.dailyTotal(streak: number): number
if streak < 0 then
return RewardConfig.daily
end
if streak > 7 then
streak = 7
end
return RewardConfig.daily + streak * RewardConfig.streakBonus
end
function RewardService.grantDaily(player: Player): boolean
local total = RewardService.dailyTotal(2)
if total <= 0 then
return false
end
if total > 500 then
return false
end
return DataService.addCoins(player, RewardConfig.maxClaim)
end
return RewardServiceExpected behavior: tuning lives frozen in Shared, logic lives in one Service, and daily grants flow through DataService with bounds.
Solution 2
Script in ServerScriptService named ServerMain
luau
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerScriptService = game:GetService("ServerScriptService")
local Packages = ReplicatedStorage:WaitForChild("Packages")
local Janitor = require(Packages:WaitForChild("Janitor")) :: any
local DataService = require(ServerScriptService:WaitForChild("DataService"))
local CoinService = require(ServerScriptService:WaitForChild("CoinService"))
local ShopService = require(ServerScriptService:WaitForChild("ShopService"))
DataService.start()
local janitor = Janitor.new() :: any
if janitor == nil then
return
end
local coinsApi = {
getCoins = function(player: Player): number
return DataService.getCoins(player)
end,
}
CoinService.start(janitor, coinsApi)
ShopService.start(janitor, coinsApi, DataService)Expected behavior: no Service requires its sibling, the bootstrap injects one coins API, and both Services consume it after DataService starts. Janitor construction new follows the Janitor docs.
Solution 3
ModuleScript in ServerScriptService named JoinCheck
luau
--!strict
local Players = game:GetService("Players")
local ServerScriptService = game:GetService("ServerScriptService")
local DataService = require(ServerScriptService:WaitForChild("DataService"))
local JoinCheck = {}
function JoinCheck.start(): ()
Players.PlayerAdded:Connect(function(player: Player): ()
task.defer(function(): ()
local first = DataService.getCoins(player)
local second = DataService.getCoins(player)
if first ~= second then
return
end
print(first)
end)
end)
end
return JoinCheckExpected behavior: joins read twice after defer, both reads match, and Output shows the loaded total instead of zero.
Summary
Keep Services authoritative and singular. Keep Controllers thin and intent-only. Keep Shared frozen and side-effect free. Keep persistence in DataService and networking in Comm. Wire order in small bootstraps. Next, choose packages first in Packages.