Eliminated the static `JpaUtils` utility and transitioned to explicit repository management for improved modularity and testability. Updated `GuildService`, `TaskManagerImpl`, and related classes to use dependency-injected repositories. Simplified configuration by aligning repository definitions with existing service patterns and ensuring proper session handling through `SessionManager`. Verified that all affected components function as expected after refactoring.
20 KiB
sessionId
| sessionId |
|---|
| session-260925-080906-1d5c |
Findings
Correctness bugs (highest priority)
| # | Where | Problem |
|---|---|---|
| B1 | BlacklistRepository.delete/add |
getWordFromDb loads the entity in its own session (which it also closes twice). The caller then calls session.remove(bl) in another session on that detached entity, which Hibernate rejects with IllegalArgumentException. The exception is swallowed by BlacklistService, which returns warn. |
| B2 | GuildSettingRepository.setSettingValue |
Runs inside JpaUtils.getWithSession, which has no transaction, so merge/remove are never flushed or committed. Updating or resetting a setting is probably lost; only an IDENTITY persist happens to insert. |
| B3 | Repository<O> |
IDs are always String, but OBlacklist.id / OGuildSetting.id are Integer, so deleteBy/findById bind a type-mismatched parameter. BlacklistRepository.findById returns null outright. |
| B4 | Repository.save, JpaUtils.updateWithTransaction |
Every non-constraint exception is swallowed after logger.error(e), so callers think the write succeeded. runWithTransaction never rolls back on failure. |
| B5 | JpaUtils.tryLoad |
Uses the deprecated Session.load(Object, id). It closes a session it doesn't own and calls back into GuildService (a hidden circular dependency). |
| B6 | MessageRepository.getMessageString |
NPE when the message isn't found. |
| B7 | SessionManager.getSessionFactory |
Lazy initialization isn't thread-safe. The Hibernate factory is built before Flyway migrates, and the schema is never validated. |
Design / best-practice issues
- Static service locator.
JpaUtils.bind/bindGuildServiceLookup(called fromApp.javal.119, l.138) hide dependencies and make repositories untestable. - Duplicated CRUD. Five repositories copy-paste
findAll/findById/deleteBy. They use the "lambda + externalArrayList" workaround because the helpers can't return values. - Duplicated JDBC config.
SessionManagerandDatabaseMigrationboth build the URL, user and password from settings. - Layering. Repositories take JDA types (
Member,Message,MessageChannel), andMessageRepository.logcarries a//TODO: move to a service. - Services create their own repositories with
new.TaskService.persistTask/unpersistTaskeven shadow the repository field it already holds. - Caches.
GuildSettingService.reloadSettingsCacheclears the map and then refills it, so readers can see it empty.getSettings()andGuildServiceProviderImpl.getServices()leak mutable maps. Guild services are never evicted. - Logging mix. The package uses both Log4j
LogManagerand SLF4J. - Entities. No
equals/hashCode. The field is namedOLoggings.OWebUseris missing fromhibernate.cfg.xml. - Hygiene. Duplicated copyright headers and a broken
package ...;/*line inGuildRepositoryandGuildSettingRepository.GSettingshas a//TODO: description. - No tests for the
dbpackage.
Requirements
Overview & Goals
Make the persistence layer (me.bobert.netneutrality.db and subpackages) correct, testable and idiomatic for Hibernate 7.1 / Java 25. Services become pure domain code with no JDA or command types. User-visible bot behavior stays the same except for the bug fixes (writes actually persist, errors surface).
Chosen options: Full redesign, injected generic base repository, Testcontainers MySQL tests.
Scope
In scope
- Fixes for bugs B1–B7 (see Findings).
- Replace the static
JpaUtilswith constructor-injectedSessionProviderand a genericAbstractRepository<T, ID>. - A single
DatabaseConfigshared by Hibernate and Flyway. Migrate first, then build the factory withhbm2ddl.auto=validate. - Service cleanup: inject repositories, safe cache swaps, no JDA types in repositories.
- Decouple services from JDA and
CommandExecutionResult. Services take plain IDs and return domain results. The callers (BlacklistCommand,ConfigureCommand,MusicCommand,MusicManager,EventsHandler,ActionRoutes,BotState,TaskManagerImpl,LoadPlaylistTask) map those results to replies. - Rework the guild-service cache.
GuildServiceProviderkeys by guild ID only, evicts onGuildLeaveEvent, and exposes read-only snapshots. - Entity hygiene and consistent logging (Log4j API, matching
AppandSessionManager). - Testcontainers-based integration tests.
Out of scope
- Switching to Spring or JPA
EntityManager, or changing the Flyway schema (a migration is added only if validation shows an actual mismatch). - Changing command UX: slash command names and options, and the reply wording (messages stay equivalent).
- Changes to
CommandExecutionResultitself or to the command framework (core.command).
Functional Requirements
- Adding or removing a blacklist word persists, and the cache reflects it right away.
- Setting a guild setting to a non-default value inserts or updates it. Setting it to the default or
nulldeletes the row. All of this happens in one transaction. - Persistence failures propagate as exceptions and are rolled back. They are never silently logged.
- When the bot starts: Flyway migrates first, then Hibernate validates the schema. If either fails, startup aborts with a clear error (Sentry is kept for non-dev environments). The password is never logged.
- A missing
OGuildgets recreated throughGuildService.findOrCreateGuildinstead of thetryLoadside channel. - No class in
db.serviceordb.repositoryimportsnet.dv8tion.jda.*orme.bobert.netneutrality.core.command.*. - The blacklist add/remove replies stay equivalent: success, "already exists" and "not found" become distinct outcomes, and unexpected errors are shown as a warning by the command.
- When the bot leaves a guild, its row is deleted and its cached
GuildServiceis evicted.
Technical Design
Key Decisions
- Constructor injection instead of a static locator. Repositories receive the
SessionProvider(not theSessionFactory), because the factory is only built instart().JpaUtilsis deleted. - Use Hibernate 7's native helpers
SessionFactory.fromTransaction/inTransaction/fromSession. They handle begin, commit, rollback and close correctly, so no hand-written transaction code is needed. - Generic ID type.
AbstractRepository<T, ID>takes the entity class and usessession.find,mergeandremoveon a managed instance. - Queries and writes run in the same session. This fixes B1 and B2 by construction.
- Migrate, then validate. Flyway runs before the factory is built, and
hibernate.hbm2ddl.auto=validatecatches drift between entities and schema. - Services return domain outcomes; commands turn them into replies. Services return small enums/records such as
BlacklistResult { ADDED, REENABLED, ALREADY_EXISTS, REMOVED, NOT_FOUND }and neverCommandExecutionResult. They takeStringIDs, notMember/Guild/Message. Unexpected persistence errors propagate, and the command layer catches them and shows a warning. - ID-only provider API.
GuildServiceProvider.get(Guild)is removed (callers passguild.getId()). The provider addsevict(String), andgetServices()becomessnapshot(), which returnsMap.copyOf.
Data Models / Contracts
public record DatabaseConfig(String url, String user, String password) {
static DatabaseConfig from(SettingsProvider s) { ... } // MYSQL_* keys, same defaults
}
public abstract class AbstractRepository<T, ID> {
protected final SessionProvider sessions;
private final Class<T> type;
protected AbstractRepository(SessionProvider sessions, Class<T> type)
public List<T> findAll() // fromSession + criteria/HQL
public Optional<T> findById(ID id) // session.find
public T save(T entity) // fromTransaction(s -> s.merge(entity))
public void deleteById(ID id) // inTransaction: find + remove
protected <R> R read(Function<Session, R> work) // sf.fromSession
protected <R> R write(Function<Session, R> work) // sf.fromTransaction
protected void writeVoid(Consumer<Session> work) // sf.inTransaction
}
Repositories and their key queries:
GuildRepository extends AbstractRepository<OGuild, String>.BlacklistRepository<OBlacklist, Integer>:findByGuildIdfindEnabledWords(guildId)returnsSet<String>enableWord(OGuild, creatorId, word)deleteWord(guildId, word)
GuildSettingRepository<OGuildSetting, Integer>:findByGuildupsertOrDelete(guildId, name, value, default), which queries the setting directly instead of using the lazy collection.
TaskRepository<OTask, String>.MessageRepository<OLogMessage, String>:findByMessageIdupdateEditedMessagefindByUser(... ORDER BY dateSent DESC)log(...)takes plain values (IDs, content,Instant) instead of JDA types.
Service contracts after the redesign:
public enum BlacklistResult { ADDED, REENABLED, ALREADY_EXISTS, REMOVED, NOT_FOUND }
public class BlacklistService {
BlacklistResult add(String creatorUserId, String word);
BlacklistResult remove(String word);
List<BlacklistEntry> list(); // record BlacklistEntry(String word, boolean enabled)
boolean containsBlacklistedWord(String text);
}
public class GuildSettingService {
void set(GSettings key, @Nullable String value); // null/default => delete
Optional<String> get(GSettings key);
Map<String, String> snapshot(); // unmodifiable
}
public interface GuildServiceProvider {
GuildService get(String guildId);
void evict(String guildId);
Map<String, GuildService> snapshot();
}
Proposed Changes
SessionManager- Hold a
DatabaseConfig. start()runsDatabaseMigration.migrate(config)and then builds the factory eagerly (synchronized/volatile).getSessionFactory()throws if called beforestart().- Remove
JpaUtils.bind. - Add
hibernate.hbm2ddl.auto=validatetohibernate.cfg.xmland mapOWebUserthere if it has a table.
- Hold a
DatabaseMigration: takesDatabaseConfig.- Services get their dependencies through constructors:
GuildService(String guildId, GuildRepository, BlacklistRepository, GuildSettingRepository).updateDebugModebecomesoGuild = guildRepository.save(modified).BlacklistService.reloadBlacklistCacheusesfindEnabledWords(notryLoad).GuildSettingServicebuilds a new map and swaps it into avolatilefield.getSettings()returns an unmodifiable view.TaskServicereceivesTaskRepositoryand uses the field. It parses the payload once.
GuildServiceProvider/GuildServiceProviderImplfollow the ID-only API: the provider is constructed with the repositories, usesConcurrentHashMap.computeIfAbsent, and hasevictandsnapshot.- Callers updated to the new service API:
BlacklistCommandmapsBlacklistResultto success/warn replies, passescontext.getGuildUser().getId(), and lists entries throughlist().ConfigureCommandusesset(...)/snapshot().MusicCommand,MusicManager,MusicServiceImpl,ActionRoutes,BotState,LoadPlaylistTaskandTaskManagerImpluseget(String)andsnapshot().EventsHandler: onGuildLeaveEventit callsdeleteGuild()and thenevict(id).
App.java:- Build the repositories from the registered
SessionManager. - Pass them to
GuildServiceProviderImplandTaskService. - Remove
JpaUtils.bindGuildServiceLookup. - Update the callers of
MessageRepository.log.
- Build the repositories from the registered
- Entities:
- Add ID-based
equals/hashCode(with a null-safegetClass/proxy-aware pattern). - Rename
OLoggingstologgings.
- Add ID-based
- Cleanup: fix the headers, standardize on the Log4j API, and document
GSettings.
File Structure
- New:
db/DatabaseConfig.javadb/service/BlacklistResult.java,db/service/BlacklistEntry.javadb/AbstractRepository.java(replacesdb/Repository.java)src/test/java/me/bobert/netneutrality/db/**tests
- Modified:
SessionManager,SessionProvider(if needed),DatabaseMigration- all 5 repositories, all services,
GuildServiceProvider - the callers:
BlacklistCommand,ConfigureCommand,MusicCommand,MusicManager,MusicServiceImpl,EventsHandler,ActionRoutes,BotState,TaskManagerImpl,LoadPlaylistTask - the entities,
hibernate.cfg.xml,App.java,pom.xml(test dependencies)
- Deleted:
db/JpaUtils.java,db/Repository.java.
Architecture Diagram
graph LR
App --> SessionManager
SessionManager -->|migrate first| DatabaseMigration
SessionManager -->|builds| SF[SessionFactory]
App --> Repos[AbstractRepository subclasses]
Repos -->|fromTransaction| SF
App --> GSP[GuildServiceProviderImpl]
GSP --> GuildService
GuildService --> BlacklistService
GuildService --> GuildSettingService
BlacklistService --> Repos
GuildSettingService --> Repos
App --> TaskService
TaskService --> Repos
Risks
validatemay fail on existing drift between the entities and V1/V2. Mitigation: the integration test catches it; fix the mapping or addV3__*.sql.- Callers relied on swallowed exceptions. Commands such as
BlacklistCommandandConfigureCommandcatch them and show a warning reply. The listener andLogHandlerpaths are reviewed so errors stay logged and don't crash event handling. - Wide caller change set from the API redesign. Mitigation: do it in its own stage, keep the method semantics identical, and rely on the compiler and
./mvnw testto catch every call site. - Lazy associations accessed outside a session (for example
OGuild.getBlacklists()elsewhere). Mitigation: search for the getters and replace them with repository queries.
Testing
Validation Approach
- Run
./mvnw testafter each stage. Existing tests (for exampleApplicationContextTest) must keep passing. - Add the
org.testcontainers:mysqlandjunit-jupitertest dependencies. - Add a shared
MySqlTestBasethat:- starts
mysql:8 - builds a
DatabaseConfig/SettingsProviderstub - starts a real
SessionManager(Flyway plus validate) - uses
@Testcontainers(disabledWithoutDocker = true)
- starts
Key Scenarios
SessionManagerstarts against the container: the migrations apply and schema validation passes.BlacklistRepository: adding a word and then deleting it removes the row (regression for B1). Re-adding a disabled word re-enables it.GuildSettingRepository: setting, updating and resetting to default each persist the expected state (regression for B2).AbstractRepository:findById/deleteByIdwork withIntegerandStringIDs (B3). A failing write throws and leaves no partial data (B4).GuildService.findOrCreateGuildcreates the guild once, and a second call returns the existing row.GuildSettingService: readers never observe an empty cache during a reload (unit test with Mockito).BlacklistServicereturnsADDED, thenALREADY_EXISTS, thenREMOVED, thenNOT_FOUNDfor the matching sequence of calls (container test).BlacklistCommandmaps eachBlacklistResult(and a thrown exception) to the expected reply type (Mockito unit test).GuildServiceProviderImpl.evictremoves the cached service, and the nextgetbuilds a new one.- Architecture check: a test scans
db.service/db.repositoryclasses to confirm there are nonet.dv8tion.jdaorcore.commandimports (a simple source-scan test).
Edge Cases
MessageRepository.findByMessageIdfor an unknown ID returns empty (no NPE).SessionManager.getSessionFactory()beforestart()throwsIllegalStateException.TaskService.persistTaskwith an unregistered type is a no-op.
Delivery Steps
✓ Step 1: Rework session bootstrap and add a generic base repository
SessionManager migrates first, then builds a validated SessionFactory, and repositories can extend a type-safe injected base class.
- Add
db/DatabaseConfigrecord built from theMYSQL_*settings. Use it in bothSessionManagerandDatabaseMigration. SessionManager.start():- runs Flyway first
- then builds the factory eagerly and thread-safely
- keeps the Sentry/dev logic
- does not log the password
getSessionFactory()throws beforestart().- Add
hibernate.hbm2ddl.auto=validatetohibernate.cfg.xmland mapOWebUserif it has a table. - Add
AbstractRepository<T, ID>, which takes aSessionProviderand implements:findAllfindByIdreturningOptionalsavereturning the merged entitydeleteByIdread/writehelpers built onfromSession/fromTransaction/inTransaction
- Add the Testcontainers test dependencies and a
MySqlTestBase, plus a startup and schema validation test.
✓ Step 2: Migrate repositories onto the base class and fix persistence bugs
All five repositories use injected sessions with correct ID types, and the blacklist and setting write bugs are fixed and covered by tests.
- Convert
GuildRepository,BlacklistRepository,GuildSettingRepository,TaskRepositoryandMessageRepositorytoAbstractRepositorywith proper ID types. - Blacklist:
enableWord/deleteWordquery and modify inside the same transaction (fixes the detached remove).- Add
findEnabledWords(guildId).
- Guild settings:
upsertOrDeleteruns in a transaction and queries the setting directly. - Messages:
- JDA-free
log(...) updateEditedMessageOptionallookups- ordered
findByUser
- JDA-free
- Remove the swallowed-exception patterns and fix the duplicated headers and package lines.
- Add repository integration tests for the blacklist, settings, ID types and rollback scenarios.
✓ Step 3: Refactor services and wiring, and remove JpaUtils
Services receive their repositories through constructors, caches are thread-safe, and the static JpaUtils and Repository classes are gone.
GuildService:- takes its repositories through the constructor
updateDebugModeusessavefindOrCreateGuildhandles missing guilds, replacingtryLoad
BlacklistServicereloads its cache throughfindEnabledWords.GuildSettingServiceswaps in a new cache map and exposes it unmodifiable.TaskService:- uses the injected
TaskRepositoryfield - parses the payload once
- uses the injected
GuildServiceProviderImplis constructed with the repositories and usescomputeIfAbsent. The API changes come in Step 4.- Update
App.java:- build the repositories from
SessionManager - remove
JpaUtils.bindGuildServiceLookup - update the
MessageRepository.logcallers
- build the repositories from
- Delete
JpaUtils.javaandRepository.java. Replace any lazy-collection access outside a session with repository queries. - Add service tests: Mockito for the caches, and the container test for
findOrCreateGuild.
✓ Step 4: Decouple services from JDA and command types, and update callers
Services expose JDA-free, ID-based domain APIs, and every caller maps domain results to replies.
- Add
BlacklistResultandBlacklistEntry.BlacklistService.add/remove/listtake plain IDs and return domain results instead ofCommandExecutionResult. GuildSettingServiceexposesset/get/snapshot.GuildServiceProviderbecomes ID-only: removeget(Guild), addevict, and replacegetServiceswithsnapshot.- Update the callers:
BlacklistCommandmaps results and exceptions to repliesConfigureCommandMusicCommand,MusicManager,MusicServiceImplActionRoutes,BotStateTaskManagerImpl,LoadPlaylistTaskEventsHandlerevicts on guild leave
- Add tests: the
BlacklistServiceresult sequence (container),BlacklistCommandreply mapping (Mockito), provider eviction, and the no-JDA-imports source scan.
✓ Step 5: Entity and package hygiene
Entities follow JPA best practices and the package uses consistent conventions, with the full test suite green.
- Add ID-based, proxy-safe
equals/hashCodeto the entities. - Rename
OGuild.OLoggingstologgings. - Standardize logging on the Log4j API across
db.service. - Document the
GSettingsentries by resolving the TODO. - Re-run schema validation. If entity/schema drift shows up, add a
V3__*.sqlmigration or fix the mapping. - Run
./mvnw testto confirm the existing and new tests pass.