Module Loader Questions

Hi,

I’m currently using a Single Script Architecture (SSA) setup, where one script is responsible for loading all client or server modules. I’m trying to get a better understanding of how module loaders actually work within this context.

Here are some stuff I’m confused about:

  1. Scope of Loading:
    Does a module loader automatically load ModuleScripts within its environment (e.g. descendants), or do I need to explicitly organize my modules into folders like “Controllers” or “Services” for it to work properly?

  2. Non standard Modules:
    Let’s say I have a server-side module for handling promo codes (e.g. connecting to remote events for redemption). This should run as soon as the server starts, but it doesn’t really fit under “Services” or “Controllers”, so I put it in a separate “Systems” folder.
    The problem is, if my loader is only targeting the “Services” folder, this promo code module won’t initialize at runtime. What’s the best way to handle modules like this?

Any help is appreciated, thanks!

In my opinion, I think any answer to these questions will be subjective and ultimately come down to whatever you feel is best when organizing your project. Here’s how I set up my projects:

  1. Scope of Loading:
    When initializing my module loader, I give it an explicit list of modules I want it to initialize and run instead of having it automatically search a folder or its own children, etc. I do this for a couple reasons:

    1. I think it’s nice being able to know the module loader won’t do anything behind the scenes and will work directly with what you give it. For example, maybe the behavior of the module loader changes to include descendants of descendants as modules. At some point, you may forget about the change and wonder why it’s trying to load unrelated scripts.
    2. For testing purposes. When testing the behavior of specific modules, it’s nice to not have to load every single module when there’s no need to. I can also replace dependent modules with mock ones and the testing module and module loader won’t know the difference.
  2. Non standard Modules:
    I guess I don’t really have this issue with my setup. For me, I don’t have concepts that differentiate between “services” or “controllers”. At the highest level, everything is a module that has a certain lifecycle attached to it. Of course, within those modules though, I can begin to organize the code into concepts like that but the module loader has no knowledge nor does it care about anything except the modules itself.

Ultimately, it’s really up to you to decide how you want to set things up. There’s pros and cons to each approach but there’s not really right or wrong answer.

Yeah. A lot of this is just subjective and style based. My SSA basically just has a core script with a bunch of modules underneath that are self contained (don’t rely on the other modules at all, but may react to changes they push to the game model) I then load those. Any shared modules either go into a utilities folder in server script or in the ReplicatedStorage depending and any of my core modules that need it will directly load the modules they use. I technically have a piece in my core script for defining a strict load order, but it’s rarely ever used because the modules underneath are designed to work as independently as possible and require their own tools (often their children or occasionally a sharedModule).

So my core loader basically just does this

for _, v in ipairs(script:GetChildren()) do
    task.spawn(function() require(v) end)
end

Though I do technically check it’s a module before loading it. Each module sets up itself on load which is part of the reason I spawn a task, the other reason is if one crashes I don’t want it to break the loader.

1 Like

So my three folders within the client contain Controllers, Systems and Components. In my example with the “Promo - Codes”, I don’t think it is a core system such that it should be put in Controllers so I’d put it in Systems. However, by putting it in systems that means the module loader won’t load it on start meaning I’d manually have to load it.

Maybe I’m obsessed with organization but to me it feels like everything to do with UI should be in one place and likewise with other modules, so like the “Promo - Codes” should be a child of “UIController”.

I heard that module loaders also cache the modules they load so that other modules can require them without having to manually write out the path to the modules it needs.

If that’s true, then let’s say at the top of a module you require the module loader itself, which in turn requires other modules. Could that cause problems like circular dependencies or unintended behaviour? How do you usually handle requiring other modules?

That’s fine but then you need to implement your loader to work around the organizational constraints your creating. If a “Service” to you is something that should always initialize at runtime, then have your loader target services only.

Maybe you can reference the promo code module in a service and initialize it when the service initializes? This way you can keep your organizational structure and you’re still initializing what you want to initialize.

This is what mine does. It’s also nice to cache the modules if you have a shutdown process for your modules when the game stops

I actually ran into this issue and did a couple things to fix this:

  1. I switched to passing in the list of modules that I wanted to use while initializing the module loader. This means the modules are required outside of the module loader so the module loader will never require the modules it’s initializing.
  2. Instead of requiring the module loader in the modules, each module has a reference to the loader in which it was initialized. It gets set at runtime from the module loader and means the modules themselves don’t have a direct hard reference to the module loader.

To require a module from another module, I just use that reference to the loader that was set at runtime to query for other modules. If they exist, I’ll use them like I normally would if I had required them directly.

1 Like

They can depending on design. I will note that subsequent requires will technically return a cache anyways, so the script itself technically would only have to help them find the script. Though many will directly cache it anyways to simplify the workflow.

This is a little complicated though and depends on the exact intention of the circular dependency. Firstly I would state that you should attempt to avoid dependencies like that wherever possible. If you have a lot of them that usually means you are doing something wrong. Sometimes a circular dependency is the simplest way to do something though so it is possible, but needs to be managed carefully. With respect to this you mostly just need to make sure that modules you require return without waiting explicitly on another module. So like for example if one script waits for a module to load, and the module it’s trying to load is waiting on the first script, you will have effectively deadlocked it since neither script can fully load without the other loading. If instead you queue up a task to wait for the other script to expose it’s functions, but then the scripts expose their functions, they won’t permanently stall each other.

Also this isn’t really directly related, but I recall I wrote a loader like this a long time ago and I include it here, but I will note that I don’t actually ever use this. My style is based more on explicitly loading everything directly without any circular dependencies. While a loader like this can simplify some tasks, I find that it really doesn’t help me most of the time.

My loader I wrote forever ago and no longer use because it doesn't really fit my style

A brief description
This tracks all module scripts that are a DESCENDANT of SOURCE (pointed wherever you wish). If it has a truthy attribute called “required” it will preload it immediatly when this loader gets required the first time. Otherwise it just notes the name of the service down and doesn’t load it until something directly requests it (lazy loading)

There are really only 2 functions to interact with it
module.GetService(name) which will check if the name is a module it is tracking and load it for you or return the cached load
module.PromiseService(name, callback) which will do the same thing except your code won’t wait and will move on, but you create a function that the system will call once it loads the name. The only argument it will supply the callback is the loaded service. So if you want to like for example send some data through a service you can just tell it how to do that, and the loader will manage it for you while your code can just move on instead of waiting for it to load.

local SOURCE = script.Parent

local ServiceHandler = {}

local Services = {}
local CachedServices = {}

local Promises = {}

local _require = function(s)
	if s == script then return end
	
	local data
	local success, response = pcall(function()
		data = require(s)
	end)
	
	if not success then
		error(response)
	end
	
	return data
end

local function loadService(name) --if a func is called, all functions will get called back with data when loaded.  This is essentially a promise.
	if CachedServices[name] then
		return CachedServices[name]
	end
	
	if Services[name] then
		CachedServices[name] = _require(Services[name])
		return CachedServices[name]
	else
		error("No services with name " .. name .. " so no service was loaded")
	end
end

local function promiseService(name, callback)
	if CachedServices[name] then
		if callback then
			coroutine.wrap(callback(CachedServices[name]))()
		end
		return
	end
	
	if Promises[name] then if callback then table.insert(Promises[name], callback) return end end
	
	Promises[name] = callback and {callback} or {}
	
	coroutine.wrap(function()
		loadService(name)
		for _, c in pairs(Promises[name]) do
			coroutine.wrap(c)(CachedServices[name])
		end
		Promises[name] = nil
	end)()
end



function ServiceHandler.GetService(name)
	return loadService(name)
end

function ServiceHandler.PromiseService(name, callback)
	promiseService(name, callback)
end


local function addToServices(child)
	if not Services[child.Name] then
		Services[child.Name] = child
		if child:GetAttribute("required") then
			return child
		end
	else
		warn("Duplicate name: '" .. child.Name .. "' so data was not loaded for duplicate   Locations: ", Services[child.Name]:GetFullName(), child:GetFullName())
	end
end


local function setup(src)
	local PreloadServices = {}
	
	--breadth first
	local openList = {src}
	while #openList > 0 do
		local temp = table.remove(openList)

		for _, child in pairs(temp:GetChildren()) do
			if child.ClassName == "Folder" then
				table.insert(openList, child)
			elseif child.ClassName == "ModuleScript" then
				local d = addToServices(child)
				if d then
					table.insert(PreloadServices, d.Name)
				end
			end
		end
	end

	for _, name in pairs(PreloadServices) do
		promiseService(name)
	end
end

SOURCE.DescendantAdded:Connect(function(d)
	if d.ClassName == "Folder" then
		setup(d)
	elseif d.ClassName == "ModuleScript" then
		local scr = addToServices(d)
		if scr then
			promiseService(scr.Name)
		end
	end
end)

setup(SOURCE)

return ServiceHandler

Thank you for the explanation,

One more question I have is that should services and controllers ever be interacted with by other modules? Like say for example you have a DataService which handles player data. Should it ever be required by another module other than the loader?

Here is a quick loader I wrote up:

local ServerScriptService = game:GetService("ServerScriptService")
local StarterPlayer = game:GetService("StarterPlayer")

local RunService = game:GetService("RunService")

local Services = RunService:IsServer() and ServerScriptService.Server.Server
local Controllers = RunService:IsClient() and StarterPlayer.StarterPlayerScripts.Client.Client

local startTime = os.clock()

local ModuleLoader = {}
ModuleLoader._cachedModules = {}

local function LoadModule(module: ModuleScript)
	local success, result = pcall(require, module)
	if not success then
		warn("[" .. module.Name .. "] has failed to start. | " .. result)
		return false
	end
	ModuleLoader.cachedModules[module.Name] = result
	return true
end

function ModuleLoader:LoadDirectory(directory)
	for index, moduleScript: ModuleScript in pairs(directory:GetChildren()) do
		if not moduleScript:IsA("ModuleScript") then
			continue
		end
		LoadModule(moduleScript)
	end	
end

function ModuleLoader:LoadControllers()
	if RunService:IsServer() then
		warn("[ModuleLoader] - Cannot load controllers on server!")
		return false
	end
	ModuleLoader:LoadDirectory(Controllers)
end

function ModuleLoader:LoadServices()
	if RunService:IsClient() then
		warn("[ModuleLoader] - Cannot load services on client!")
		return false
	end
	ModuleLoader:LoadDirectory(Services)
end

return ModuleLoader

Probably a bit annoyingly my answer is going to again be it’s based on how you design it. In practical terms though unless you have some specific gating logic or dynamic dependency injection or something in a loader, there is no practical difference between directly loading a module via require or having the loader pass it to you.

Though I guess your example as far as I can tell just directly loads independent scripts and doesn’t allow other scripts to ask for a module itself. In that case the answer would be technically no but maybe yes? The technically no is if the script is completely self contained and isn’t meant to be used elsewhere then you of course don’t need to require it anywhere else beyond the loader which basically exists to just get it to run. If it has an interface though you intend to be able to grab then it makes sense for other scripts to be able to load it. Though you may want to have them do so through your loader to ensure a different script running first doesn’t break load order.

1 Like

Yeah I understand a lot more now.

Only reason I wanted to do this is because I wanted to avoid writing out the whole path to a module which is a minor inconvenience nevertheless. Take for example in multiple UI modules I have to write out the path to access a Tweening module.

I didn’t include a :Get method because I cant think of any case where a service will need a service as I wanted them to be self contained.

1 Like

This topic was automatically closed 14 days after the last reply. New replies are no longer allowed.