28 KiB
sessionId
| sessionId |
|---|
| session-260826-092009-17wb |
Requirements
Overview & Goals
Replace the singleton/static-heavy bootstrap of the bot with an explicit, deterministic lifecycle-managed application driven by a small hand-rolled ApplicationContext and a Lifecycle contract. Dependencies are wired through constructors (including for reflectively-loaded commands and tasks), and interfaces are introduced for the key services so the code becomes testable, maintainable, and mockable — without adopting Spring Boot or any DI framework.
The current design (documented in docs/architecture.md) uses:
NetNeutralityas a static singleton holdinginstanceandjda, with a manual, order-sensitivestartup()/shutdown()chain.- Static singletons everywhere:
Settings.getInstance(),SessionManager(staticgetSessionFactory/shutdown),CommandManager(staticinit/maps),TaskManager.getInstance(),MusicManager(static factory + staticplayerManager),ThreadManager(static executors),ScheduledTaskManager(static),GuildService.get(...)(static cache). ReflectionHelperinstantiating commands/tasks via no-arg constructors, which forces them to reach out to singletons.
Scope
In Scope
- A
Lifecycleinterface (start()/stop()/priority()) and anApplicationContextthat owns instances, wires them, and drives ordered startup/shutdown. - Converting the core singletons to constructor-injected instances.
- Introducing interfaces for key services (settings, persistence/session, executors, command registry, task manager, music service, guild-service provider) — decision confirmed with the user.
- Reworking
ReflectionHelper+ command/task base classes to use constructor injection (decision confirmed). - Symmetric, deterministic startup/shutdown replacing
NetNeutrality.startup()/shutdown()and the JVM shutdown hook. - Reorganizing the package layout into
bootstrap/framework/platform/feature/data/shared(see the Package Structure tab), done in the same effort as the DI refactor so imports are touched once. This includes renaming the entry pointNetNeutrality→App.
Out of Scope
- Introducing Spring Boot, Guice, or Dagger (explicitly rejected — manual context chosen).
- Full interface-per-service/repository abstraction (only key services get interfaces).
- Dropping the
O*/G*entity name prefixes (optional cosmetic churn, deferred). - Behavioral/feature changes to commands, tasks, music, or the web API — this is a structural refactor only.
- Database schema / Flyway migration changes.
User Stories
- As a maintainer, I want services created and wired in one explicit place so the dependency graph and startup order are obvious and not implicit.
- As a maintainer, I want symmetric
start()/stop()for every component so shutdown reliably tears down what startup created, in reverse order. - As a developer, I want commands/tasks to receive their dependencies via constructor so they no longer call
getInstance()and can be unit-tested with mocks. - As a developer, I want key services behind interfaces so I can mock them in tests (building on the existing JUnit 5 + Mockito setup).
Functional Requirements
- The bot must start and shut down with identical externally-observable behavior to today (same ordered steps from
docs/architecture.md). - Startup order must be preserved: persistence/session → migrations → executors → command/task registries → music sources → JDA → web API → presence.
- Shutdown must run in reverse of startup: cancel scheduled tasks → disconnect music players → shut down executors → shut down JDA → release the Hibernate registry.
- Commands and tasks discovered by
ReflectionHelpermust continue to auto-load ("drop a class in the package" model) but now receive dependencies via constructor. - No public command/task behavior changes.
Non-Functional Requirements
- No new runtime dependencies (manual container only).
- Incremental & low-risk: migrate one area at a time using temporary static shims that delegate to the context, so the build stays green throughout.
- Compatibility: fat-JAR packaging and
mainentry point (me.bobert.netneutrality.NetNeutrality) remain unchanged. - Testability: instance + interface-based services must be mockable with Mockito.
Technical Design
Current Implementation
- Bootstrap —
NetNeutrality(src/main/java/me/bobert/netneutrality/NetNeutrality.java): staticinstance, staticjda,main()buildsSettingsimplicitly, constructor callsstartup()which chainsSessionManager.getSessionFactory()→DatabaseMigration.migrate()→CommandManager.init()→MusicManager.initPlayerManager()→TaskManager.getInstance().init()→initJDA()→CommandManager.registerSlashCommands(jda)→new EventsHandler()→new WebApi(port)→ presence → shutdown hook.shutdown()runsScheduledTaskManager.cancelAll()→MusicManager.disconnectPlayers()→ThreadManager.shutdownExecutor()→disconnect()→SessionManager.shutdown(). - Singletons/statics:
Settings(utils/Settings.java),SessionManager(core/db/SessionManager.java, all-static),CommandManager(core/command/CommandManager.java, staticcommandsmap + staticinit),TaskManager(core/tasks/TaskManager.java,getInstance()),MusicManager(music/MusicManager.java, staticmanagersmap + staticplayerManager+ staticget/initPlayerManager/disconnectPlayers),ThreadManager(core/thread/ThreadManager.java, static executors),ScheduledTaskManager,GuildService(db/service/GuildService.java, staticservicescache). - Reflective instantiation —
ReflectionHelper.loadClassesFromPackage(...)callss.getConstructor().newInstance()(no-arg), used byCommandManager.initCommands()andTaskManager.loadBuiltInTasks()/loadScheduledTasks(). - Consumers of singletons — ~110 call sites across commands (
command/**), tasks (tasks/**), contexts (context/**,core/context/**), HTTP handlers (http/server/**),EventsHandler, repositories/services (db/**), etc.
Key Decisions (confirmed with user)
- Manual
ApplicationContext+Lifecycle— no DI framework; the context owns instances, wires them via constructors, and drives orderedstart()/stop(). - Constructor injection for commands/tasks —
ReflectionHelperresolves a constructor that accepts the context (or specific dependencies) and passes them in; commands/tasks store deps as final fields instead of callinggetInstance(). - Lifecycle + key service interfaces — introduce a
Lifecycleinterface plus interfaces for the main services (settings, persistence/session, executors, command registry, task manager, music service, guild-service provider). Repositories and minor helpers stay concrete. - Package restructure to
bootstrap/framework/platform/feature/data/shared— separate code by reason-of-change (engine vs. cross-cutting infra vs. concrete features vs. persistence model vs. leaf helpers), consolidate each feature vertical (e.g. music) into one package, and dissolve the catch-allutils. Performed right after the DI wiring, via the IDE's safe "Move Class" refactor, so the ~110 import updates happen once. Full details in the Package Structure tab.
Proposed Changes
1. Lifecycle contract — new me.bobert.netneutrality.bootstrap.Lifecycle:
public interface Lifecycle {
void start() throws Exception;
default void stop() {}
default int priority() { return 0; } // lower = started earlier, stopped later
}
2. Application context — new me.bobert.netneutrality.bootstrap.ApplicationContext:
public final class ApplicationContext {
private final List<Lifecycle> components = new ArrayList<>();
public <T> T register(T component) {
if (component instanceof Lifecycle l) components.add(l);
return component;
}
public void start() throws Exception {
components.sort(Comparator.comparingInt(Lifecycle::priority));
for (Lifecycle c : components) c.start();
}
public void stop() {
var reversed = new ArrayList<>(components);
Collections.reverse(reversed);
reversed.forEach(Lifecycle::stop);
}
}
The context also exposes typed getters for wired services so ReflectionHelper/EventsHandler/contexts can resolve dependencies.
3. Convert singletons to instances behind interfaces (priority values in parentheses set start order):
SettingsProvider←Settings(config; constructed first, not itself aLifecycle).SessionProvider/persistence ←SessionManagerinstance implementingLifecycle(10):start()builds theSessionFactory+ runsDatabaseMigration.migrate();stop()destroys the registry.ExecutorProvider←ThreadManagerinstance implementingLifecycle(20):stop()= currentshutdownExecutor().CommandRegistry←CommandManagerinstance implementingLifecycle(30):start()=init();registerSlashCommands(jda)called after JDA is ready.TaskManagerinterface ← impl implementingLifecycle(30):start()= currentinit().- Scheduled tasks ←
ScheduledTaskManagerinstance implementingLifecycle:stop()=cancelAll(). MusicService← new factory owning the sharedAudioPlayerManager+ per-guildMusicManagermap, implementingLifecycle(40):start()=initPlayerManager();stop()=disconnectPlayers(). Per-guildMusicManagerbecomes a plain instance created by the factory.GuildServiceProvider← registry owning the guild cache (replaces staticGuildService.get).WebApibecomes aLifecycle(60):start()binds the HTTP server;stop()stops it.- JDA is built by the context (replacing static
jda) and exposed via a getter/provider.
4. Rework reflective instantiation — extend ReflectionHelper so loadClassesFromPackage accepts a factory/ApplicationContext and instantiates via a constructor that takes the needed dependencies (falling back to no-arg for classes that declare it). CommandManager.initCommands() and TaskManager.loadBuiltInTasks()/loadScheduledTasks() pass the context so commands/tasks are constructor-injected.
5. Migrate consumers off getInstance() — command/task base classes (core/tasks/impl/AbstractTask.java, AbstractStepTask, AbstractPersistableTask, core/command/impl/MusicCommand.java) and contexts (core/context/AbstractContext.java, context/**) take dependencies via constructor. EventsHandler is constructed with the context and passes services when building contexts. During migration, keep temporary static shims (e.g. Settings.getInstance() delegating to the context instance) so untouched call sites keep compiling; remove them at the end.
6. New bootstrap — NetNeutrality.main() builds the ApplicationContext, constructs and register()s services in dependency order, calls context.start(), and installs one shutdown hook calling context.stop(). Static instance/jda are removed.
Data Models / Contracts
Key new interfaces (illustrative signatures):
interface SettingsProvider { String get(String key); String get(String key, String def); String getPrefix(); }
interface SessionProvider extends Lifecycle { org.hibernate.SessionFactory getSessionFactory(); }
interface ExecutorProvider extends Lifecycle { java.util.concurrent.ExecutorService getExecutor(String name); java.util.concurrent.ScheduledExecutorService getScheduledExecutor(); }
interface CommandRegistry extends Lifecycle { CommandContainer getCommandContainer(String name); void registerSlashCommands(JDA jda); }
interface TaskManager extends Lifecycle { Task getTask(String id); void insert(CommandContext ctx, Task task); void handle(TaskContext ctx); /* ... */ }
interface MusicService extends Lifecycle { MusicManager get(net.dv8tion.jda.api.entities.Guild guild); }
interface GuildServiceProvider { GuildService get(String guildId); }
File Structure
New DI classes are created directly in their target packages (from the Package Structure tab) so they are not moved twice.
- Added:
bootstrap/Lifecycle.java,bootstrap/ApplicationContext.java; service interfacesplatform/config/SettingsProvider.java,platform/persistence/SessionProvider.java,platform/concurrency/ExecutorProvider.java,framework/command/CommandRegistry.java,framework/task/TaskManager.java[interface] + impl,feature/music/MusicService.java,data/service/GuildServiceProvider.java. - Moved / renamed (package restructure):
core→framework(engine only);core.db+db→platform.persistence+data;core.thread→platform.concurrency;hooks→platform.event;http→platform.web;utilsdissolved intoplatform.config/platform.persistence/bootstrap/shared.util;NetNeutrality→App. See the Package Structure tab. - Modified:
App.java(bootstrap),ReflectionHelper.java(constructor injection), all converted managers/services, command/task base classes,EventsHandler.java,WebApi.java, HTTP handlers,getInstance()call sites, andpom.xml/Dockerfile/startup.shfor themainClassrename. - Docs: update
docs/architecture.md(startup/shutdown + package map) anddocs/utilities-and-bootstrap.md(bootstrap) to reflect the context and new layout.
Architecture Diagram
graph TD
Main["NetNeutrality.main()"] --> Ctx[ApplicationContext]
Ctx -->|constructs & wires| Settings[SettingsProvider]
Ctx -->|register + start/stop| Session[SessionProvider]
Ctx -->|register + start/stop| Exec[ExecutorProvider]
Ctx -->|register + start/stop| Cmd[CommandRegistry]
Ctx -->|register + start/stop| Tasks[TaskManager]
Ctx -->|register + start/stop| Music[MusicService]
Ctx -->|register + start/stop| Web[WebApi]
Ctx -->|builds| JDA[JDA]
Cmd -->|constructor-injects| Refl[ReflectionHelper]
Tasks -->|constructor-injects| Refl
Refl -->|ctor deps| Impl[Commands / Tasks]
JDA -->|events| Events[EventsHandler]
Events -->|builds contexts with deps| Cmd
Events --> Tasks
Risks
- Large call-site surface (~110 usages) — mitigated by temporary static shims delegating to context instances, migrating area-by-area, keeping the build green.
- Startup ordering regressions — mitigated by encoding order via
Lifecycle.priority()and mirroring the exact sequence fromdocs/architecture.md. - Reflective constructor resolution — commands/tasks must expose a consistent injectable constructor; mitigate by supporting a no-arg fallback and validating all classes load at startup.
- Per-guild
MusicManagervs shared factory — clarify that the sharedAudioPlayerManagerand cache move to theMusicService, while per-guildMusicManagerstays a lightweight instance. SessionManager.System.exit(1)on DB failure — preserve fail-fast behavior inside the newSessionProvider.start().
Package Structure
Current Layout Problems
The tree today mixes three organizing principles at once: abstraction-vs-impl (core.command↔command, core.context↔context, core.tasks↔tasks), infra-vs-data (core.db↔db), and no split at all (music, http, utils, hooks). Consequences:
coreis simultaneously "the engine" and "misc" — it holds the framework sub-packages and loose classes (LogHandler,BotState).- A single feature is scattered: music lives in
core.command.impl.MusicCommand,command.music,context.MusicChannelContext,tasks.music, andmusic/— five places. utilsis a catch-all mixing config (Settings), reflection (ReflectionHelper), JPA glue (JpaUtils), and formatting (MessageFunction,UserFunction).- Naming drift:
db.entities(plural) vs. the singular convention elsewhere.
Guiding Principle
Separate code by the reason it changes:
- framework — the reusable bot engine (command/task/context machinery); rarely changes, knows nothing about specific commands.
- platform — cross-cutting technical services (persistence, concurrency, config, logging, event dispatch, web); changes when tech/tooling changes.
- feature — concrete commands + tasks + contexts + domain logic, grouped by feature vertical; changes most often.
- data — the persistence model (entities, repositories, services).
- shared — genuinely cross-feature leaf helpers.
Target Layout
me.bobert.netneutrality
├── App.java // was NetNeutrality (main + bootstrap entry)
├── bootstrap/ // ApplicationContext, Lifecycle, ReflectionHelper (composition root)
├── framework/ // the reusable engine (was core, minus the misc)
│ ├── command/ // Command, CommandProcessor, CommandRegistry, options, build, impl bases
│ ├── context/ // Context hierarchy, reply/
│ └── task/ // Task, TaskManager, StepTask/PersistableTask, persistence/, trait/, impl bases
├── platform/ // cross-cutting technical services (horizontals)
│ ├── config/ // Settings / SettingsProvider
│ ├── persistence/ // SessionProvider, DatabaseMigration, Repository base, JpaUtils
│ ├── concurrency/ // ExecutorProvider, ScheduledTaskManager, BaseThreadFactory
│ ├── logging/ // LogHandler
│ ├── event/ // EventsHandler (was hooks)
│ └── web/ // WebApi, handlers, WebModule (was http)
├── feature/ // concrete features, one vertical per package
│ ├── music/ // MusicService/MusicManager, QueueManager, scheduler, source/, + music commands/tasks/contexts
│ ├── moderation/ // blacklist, purge, admin commands + related services
│ ├── reaction/ // reaction commands + emoji-role tasks
│ ├── git/ // report-issue command + task
│ ├── crypto/
│ └── help/
├── data/ // the persistence model (was the "db" half of the split)
│ ├── entity/ // O* entities (entities → entity, singular)
│ ├── repository/
│ └── service/ // GuildService, GuildServiceProvider, BlacklistService, …
├── shared/util/ // MiscUtils, MessageFunction, UserFunction, GuildUtils, ContextUtils
└── exceptions/ // unchanged (already coherent)
Key Moves
core→framework, now containing only the engine (noLogHandler, noBotState, nodb, nothread).core.db(infra) →platform.persistence;db(model) →data— resolves the "dbmeans two things" problem.db.entities→data.entity.core.thread→platform.concurrency;hooks→platform.event;http→platform.web.utilsdissolved: config →platform.config, JPA glue →platform.persistence, reflection →bootstrap, formatting/misc →shared.util.- All music code consolidates under
feature.music; the other verticals group their command/task/context/service triplets similarly. LogHandler→platform.logging;BotState→bootstrap(composition-root-adjacent state).
Sequencing & Rationale
The repackage is done immediately after the DI wiring, in the same effort: the DI refactor already renames managers (CommandManager→CommandRegistry, SessionManager→SessionProvider, ThreadManager→ExecutorProvider) and touches imports at ~110 call sites, so combining them avoids editing the same imports twice. Moves use the IDE's safe "Move Class" refactor (references update automatically), one subsystem at a time, building after each. New DI classes are created directly in their target packages rather than moved later.
Lower-Risk Variant (optional fallback)
If full feature-first grouping feels too large in one pass, a high-value subset fixes ~80% of the confusion with far fewer moves: rename core → framework, extract platform (evict LogHandler, BotState, core.db, core.thread, hooks, http), and dissolve utils — while leaving the concrete command/task/context packages where they are. The trade-off is that a single feature stays spread across those layer packages.
Risks
- Move touches nearly every import — mitigated by IDE "Move Class" (auto-updates references), one subsystem per step, and a build after each move.
- Main-class rename
NetNeutrality→App— must update the ShademainClassinpom.xml, theDockerfile/startup.shentry, and docs atomically, or the fat JAR won't launch. - Feature-first churn — grouping music/moderation/etc. is the largest move; the lower-risk variant above is the fallback if needed.
Testing
Validation Approach
The refactor is behavior-preserving, so validation focuses on: (a) the project compiles and the fat JAR builds after each stage, (b) existing tests still pass, and (c) new unit tests prove services are now mockable/instance-based. Use the existing Maven + JUnit 5 + Mockito setup (./mvnw test); Checkstyle runs in the test phase and must stay green.
Key Scenarios
- Build & Checkstyle —
./mvnw packagesucceeds after every stage (shade fat JAR, main class unchanged). - ApplicationContext ordering — a unit test registers fake
Lifecyclecomponents with differentpriority()values and assertsstart()runs ascending andstop()runs descending. - Command/task loading — assert
CommandManager/TaskManagerstill discover and instantiate all implementations viaReflectionHelperwith constructor injection (nogetInstance()calls at runtime). - Existing tests —
CommandProcessorTest,SettingsTest, and any others continue to pass; update them to use injected instances/mocks instead of wrestling with singletons.
Edge Cases
- Missing
NETNEUTRALITY_BOT_TOKEN→ startup still fails fast with a clear error. - DB connection failure inside
SessionProvider.start()→ preserves current fail-fast (Sentry capture in non-dev + exit). - A command/task class that only declares a no-arg constructor →
ReflectionHelperstill loads it via the fallback path. - Shutdown with services that never fully started →
stop()must be null-safe (mirroring current guards likejda != null,registry != null).
Test Changes
- Add:
ApplicationContextTest(ordering + symmetric start/stop), aReflectionHelperconstructor-injection test. - Update:
SettingsTestandCommandProcessorTestto construct instances/mocks of the new interfaces instead of relying on static singletons. - Skip: no integration test against a live Discord gateway or real MySQL (out of scope).
Delivery Steps
✓ Step 1: Introduce Lifecycle contract and ApplicationContext bootstrap
The app boots through an ApplicationContext that drives ordered start/stop, while behavior stays identical via temporary shims.
- Add
me.bobert.netneutrality.bootstrap.Lifecycleinterface withstart(),stop()(default no-op), andpriority()(default 0, lower = started earlier / stopped later). - Add
me.bobert.netneutrality.bootstrap.ApplicationContextthat registers components, sorts bypriority(), starts ascending and stops descending, and exposes typed getters for wired services. - Refactor
NetNeutrality.main()/startup()to build the context and register the existing (still static) steps as thinLifecycleadapters, install a single shutdown hook callingcontext.stop(), and remove staticinstance/jda(temporarily bridged where needed). - Add
ApplicationContextTestasserting ascending start and descending stop ordering.
✓ Step 2: Convert foundational services to instances behind interfaces
Configuration, persistence, and executors are constructor-injected instances behind interfaces, wired into the context with correct lifecycle order.
- Introduce
SettingsProviderimplemented bySettings(instance, constructed first); keepSettings.getInstance()as a temporary shim delegating to the context instance. - Convert
core/db/SessionManagerinto aSessionProviderinstance implementingLifecycle(priority ~10):start()builds theSessionFactoryand runsDatabaseMigration.migrate(),stop()destroys the registry; preserve fail-fast on DB errors. - Convert
core/thread/ThreadManagerinto anExecutorProviderinstance implementingLifecycle(priority ~20) withstop()= currentshutdownExecutor(). - Update
JpaUtils/Repository/db/**call sites andhihims to use the injected providers.
✓ Step 3: Convert domain managers (command, task, scheduled, music, guild) to instances behind interfaces
The command/task/music/guild managers are instance-based Lifecycle components behind interfaces, registered in the context.
- Introduce
CommandRegistry(impl =CommandManager, priority ~30):start()=init();registerSlashCommands(jda)invoked after JDA is ready. - Introduce a
TaskManagerinterface + impl (priority ~30) implementingLifecyclewithstart()= currentinit(); convertScheduledTaskManagerto an instance whosestop()=cancelAll(). - Introduce
MusicServicefactory owning the sharedAudioPlayerManagerand per-guildMusicManagermap (priority ~40):start()=initPlayerManager(),stop()=disconnectPlayers(); make per-guildMusicManagera plain instance created by the factory. - Introduce
GuildServiceProviderowning the guild cache, replacing staticGuildService.get(...). - Keep static shims on each so untouched call sites keep compiling.
✓ Step 4: Enable constructor injection for reflectively-loaded commands and tasks
Commands and tasks receive their dependencies via constructor instead of calling getInstance().
- Extend
ReflectionHelper.loadClassesFromPackageto accept theApplicationContextand instantiate via a constructor taking the required dependencies, with a no-arg fallback for classes that declare it. - Update
CommandManager.initCommands()andTaskManager.loadBuiltInTasks()/loadScheduledTasks()to pass the context. - Update command/task base classes (
core/tasks/impl/AbstractTask,AbstractStepTask,AbstractPersistableTask,core/command/impl/MusicCommand) and contexts (core/context/AbstractContext,context/**,EventsHandler) to store injected dependencies as final fields. - Add a
ReflectionHelpertest proving constructor injection and no-arg fallback both work.
✓ Step 5: Remove static shims, finalize interfaces, and update tests/docs
All singletons/getInstance() accessors are gone; services are wired only through the context, and tests/docs reflect the new model.
- Migrate remaining
getInstance()/static call sites (commands, tasks, HTTP handlers inhttp/server/**,LogHandler,BotState) to injected interfaces and delete the temporary static shims and singleton fields. - Convert
WebApiinto aLifecyclecomponent (priority ~60) withstart()/stop()for the HTTP server. - Update
SettingsTestandCommandProcessorTestto use injected instances/mocks of the new interfaces; ensure./mvnw package(including Checkstyle) is green. - Update
docs/architecture.md(startup/shutdown lifecycle) anddocs/utilities-and-bootstrap.md(bootstrap) to describe theApplicationContext+Lifecycledesign.
✓ Step 6: Reorganize packages into bootstrap/framework/platform/feature/data/shared
The codebase is regrouped by reason-of-change into the target layout (see the Package Structure tab), with all references updated and the build green.
- Rename the entry point
NetNeutrality→App, and update the ShademainClassinpom.xml, plus theDockerfile/startup.shlaunch entry. - Using the IDE's safe "Move Class" refactor, one subsystem per step:
core→framework(engine only);core.db+db→platform.persistence+data(db.entities→data.entity);core.thread→platform.concurrency;hooks→platform.event;http→platform.web. - Dissolve
utils:Settings/SettingsProvider→platform.config,JpaUtils→platform.persistence,ReflectionHelper→bootstrap, formatting/misc helpers →shared.util; moveLogHandler→platform.loggingandBotState→bootstrap. - Consolidate each feature vertical (music, moderation, reaction, git, crypto, help) into
feature.*, pulling its commands/tasks/contexts/services together. - Build and run tests after each subsystem move; update
docs/architecture.md"Package map" to the new layout.