---
url: /advanced/architecture.md
description: >-
  Services, controllers, shared modules, and data ownership with dependency
  direction that holds.
---

# Architecture

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

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

* [Patterns and Boundaries](../intermediate/patterns-and-boundaries.md)
* [State and Data](../intermediate/state-and-data.md)
* [Server Authority](../roblox-fundamentals/server-authority.md)
* [Annotations and Inference](../strict-typing/annotations-and-inference.md)

## 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](https://create.roblox.com/docs/en-us/projects/server-authority) model gives you the core rule. The server decides outcomes. The client sends intents and renders results. The [authority techniques](https://create.roblox.com/docs/en-us/projects/server-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 CoinService
```

Wait 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](https://sleitnick.github.io/RbxUtil/api/ServerComm) and [RemoteSignal](https://sleitnick.github.io/RbxUtil/api/RemoteSignal) pages. The Janitor call `Add` per the [Janitor docs](https://docs.howmanysmall.com/Janitor/api/) 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](https://create.roblox.com/docs/en-us/scripting/security/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](https://sleitnick.github.io/RbxUtil/api/ClientComm) and [RemoteSignal](https://sleitnick.github.io/RbxUtil/api/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](https://create.roblox.com/docs/en-us/scripting/security/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 ShopConfig
```

It 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](https://create.roblox.com/docs/en-us/cloud-services/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](https://madstudioroblox.github.io/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 DataService
```

Scope 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](https://create.roblox.com/docs/en-us/scripting/security/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](https://sleitnick.github.io/RbxUtil/api/ServerComm) inside Services only. Client calls `GetFunction` and `GetSignal` or `BuildObject` per the [ClientComm page](https://sleitnick.github.io/RbxUtil/api/ClientComm) inside Controllers only. Validate every inbound payload with layered checks. The [remote events](https://create.roblox.com/docs/en-us/scripting/events/remote) 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](https://create.roblox.com/docs/en-us/projects/server-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 += 100
```

It fails because the server never approved the grant. Exploiters replay it at will. The [security tactics](https://create.roblox.com/docs/en-us/scripting/security/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 ShopService
```

It 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](https://madstudioroblox.github.io/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](https://sleitnick.github.io/RbxUtil/api/ServerComm). The client fires with `Fire` per the [RemoteSignal page](https://sleitnick.github.io/RbxUtil/api/RemoteSignal). Neither side requires the other.

## Exercises

1. Split a combined module into one Service plus one Shared config.
2. Move a direct Service-to-Service require into bootstrap injection.
3. Wire DataService first and prove a join reads correct coins.

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

```luau
--!strict
local RewardConfig = {
	daily = 50,
	streakBonus = 5,
	maxClaim = 1,
}
table.freeze(RewardConfig)
return RewardConfig
```

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

Expected behavior: tuning lives frozen in Shared, logic lives in one Service, and daily grants flow through DataService with bounds.
:::

::: details 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](https://docs.howmanysmall.com/Janitor/api/).
:::

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

Expected 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](./packages.md).
