Hoist-core provides three abstract base classes that form the foundation for all server-side code:
BaseServiceβ The abstract superclass for all Grails services. Provides a managed lifecycle, distributed resource factories (caches, timers, Hazelcast maps and sets), event subscriptions, identity access, logging, and admin console integration.BaseControllerβ The abstract superclass for all controllers. Provides JSON serialization/parsing, async support, OWASP encoding, identity access, and logging.RestControllerβ ExtendsBaseControllerwith a template-method CRUD pattern for managing GORM domain objects via REST endpoints.
Together, these classes establish the conventions and capabilities that every Hoist application service and endpoint inherits.
| File | Location | Role |
|---|---|---|
BaseService |
src/main/groovy/io/xh/hoist/ |
Abstract superclass for all services |
BaseController |
grails-app/controllers/io/xh/hoist/ |
Abstract superclass for all controllers |
RestController |
grails-app/controllers/io/xh/hoist/ |
CRUD controller for GORM domain classes |
Timer |
src/main/groovy/io/xh/hoist/util/ |
Managed polling timer with primaryOnly support |
The Cache and CachedValue classes created by the factory methods below are documented in
caching.md.
Every Hoist service extends BaseService and follows a well-defined lifecycle:
- Spring construction β Grails creates the service as a Spring-managed singleton.
init()β Called during application bootstrap viainitialize()orparallelInit(). Override to set up starting state, create resources, and subscribe to events.- Runtime β The service handles requests and runs timers.
clearCaches()β Can be called at any time (including from the Admin Console) to reset service state. The base implementation only updates alastCachesClearedtimestamp β it does not automatically clear caches created viacreateCache()orcreateCachedValue(). Override to explicitly clear each cache and reset any other custom state. Seecaching.mdon clearing and invalidating caches.destroy()β Called by Spring on clean shutdown. Cancels all managed timers.
The status methods isInitialized() and isDestroyed() can be used for defensive checks during
startup and shutdown.
class PortfolioService extends BaseService {
def configService
private Cache<String, Map> portfolioCache
void init() {
portfolioCache = createCache(name: 'portfolios', expireTime: 10 * MINUTES)
subscribe('xhConfigChanged', {clearCaches()})
}
void clearCaches() {
super.clearCaches()
portfolioCache.clear()
}
}BaseService provides a static method
parallelInit(Collection<BaseService> services, Long timeout = 30 * SECONDS) that initializes all
provided services in parallel (blocking until all complete). The application's BootStrap calls
this method multiple times with different groups of services to control startup ordering:
// In BootStrap.groovy β the caller decides the groups, not the services themselves.
BaseService.parallelInit([configService, prefService]) // group 1
BaseService.parallelInit([portfolioService, marketService]) // group 2BaseService provides factory methods that create managed resources β resources that are named,
tracked by the framework, and reported in the Admin Console.
All resource names must be unique within a service across caches, timers, and distributed Hazelcast objects.
Creates a Cache<K, V> β a key-value store with optional entry expiry, backed by a Hazelcast
ReplicatedMap when clustered or a local ConcurrentHashMap otherwise.
private Cache<String, List<Position>> positionCache
void init() {
positionCache = createCache(name: 'positions', expireTime: 5 * MINUTES, replicate: true)
}Accepts name, expireTime, expireFn, timestampFn, replicate, serializeOldValue, and
onChange. See caching.md for the full parameter and API reference, expiry
semantics, change-handler threading, and pitfalls.
Creates a CachedValue<V> β a single-value cache with a closely parallel set of expiry and
replication options. Ideal for an expensive computation that should be shared across a cluster.
private CachedValue<Map> summary
void init() {
summary = createCachedValue(name: 'summary', replicate: true, expireTime: 30 * MINUTES)
}Accepts name, expireTime, expireFn, timestampFn, replicate, and onChange. These mirror
their createCache() counterparts but do not behave identically. See
caching.md for how the two diverge.
Creates a managed Timer that runs a closure on a configurable interval. The Timer ensures that
only one execution runs at a time. Supports primaryOnly mode so that the task runs on only one
cluster instance.
void init() {
createTimer(
name: 'refreshData',
runFn: this.&refreshData,
interval: 5 * MINUTES,
primaryOnly: true, // only run on the primary instance
delay: true, // wait one interval before first run
timeout: 3 * MINUTES // max execution time (cancels task and logs error on timeout)
)
}
private void refreshData() {
// Called periodically by the timer
}Constructor parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
name |
String |
required | Unique name within the service |
runFn |
Closure |
null |
Closure to execute. If omitted, the timer calls onTimer() on the owning service |
interval |
Number, Closure, or String |
null |
Interval between runs. A string is interpreted as an AppConfig name. Non-positive values disable the timer |
intervalUnits |
Long |
1 |
Multiplier applied to the interval value (e.g. SECONDS) |
timeout |
Number, Closure, or String |
3 * MINUTES |
Max execution time. On timeout, the running task is cancelled (interrupted) and the timeout is logged as an error |
timeoutUnits |
Long |
1 |
Multiplier applied to the timeout value (e.g. SECONDS) |
primaryOnly |
Boolean |
false |
Only run on the primary cluster instance |
delay |
Boolean or Long |
false |
Delay before first run. true = one interval; a number = delay in ms |
runImmediatelyAndBlock |
Boolean |
false |
Run the function synchronously during construction and block until complete |
When interval is specified as a string, it is treated as an AppConfig key β the timer will
automatically adjust when the config value changes. Use intervalUnits to ensure proper unit
interpretation (e.g. when a config name like xhMarketDataRefreshSecs stores seconds, set
intervalUnits: SECONDS). Intervals below 500ms are clamped to 500ms.
Key Timer API methods:
| Method | Description |
|---|---|
forceRun() |
Request an immediate execution on the next heartbeat, or as soon as any in-progress run completes |
cancel() |
Permanently cancel the timer. In-progress executions are unaffected |
Creates a Hazelcast IMap<K, V> β a distributed, partitioned map. Unlike Cache (which uses
ReplicatedMap and is fully replicated), IMap partitions data across cluster members and is
better suited for large datasets.
private IMap<String, byte[]> fileStore
void init() {
fileStore = createIMap('fileStore')
}Creates a Hazelcast ISet<V> β a distributed set. Useful for tracking unique items across the
cluster (e.g. a set of active session IDs).
Creates a Hazelcast ReplicatedMap<K, V> β a fully replicated map where every instance holds a
complete copy. This is what Cache uses internally.
Returns a Hazelcast ITopic<M> for cluster-wide pub/sub messaging. Unlike the factory methods
above, getTopic() does not register the topic as a managed resource. Use subscribeToTopic() to
receive messages; use getTopic() directly when you need to publish messages:
getTopic('myCustomEvent').publish([action: 'refresh', source: username])Subscribe to events on the local Grails event bus (single instance only):
void init() {
subscribe('xhPortfolioLoaded') { Map msg ->
logInfo("Portfolio loaded", msg.portfolioId)
refreshSummary()
}
}The subscription automatically catches and logs exceptions (unlike raw Grails EventBus.subscribe,
which silently swallows them) and skips events on destroyed services.
Note:
subscribe()receives events on the local instance only. For cluster-wide events (including config changes), usesubscribeToTopic()orclearCachesConfigsinstead.
Subscribe to a Hazelcast topic for cluster-wide messaging:
void init() {
subscribeToTopic(
topic: 'xhConfigChanged',
onMessage: { Map msg -> handleConfigChange(msg) },
primaryOnly: false // receive on all instances (default)
)
}This is the preferred method for most pub/sub use cases since it works across multiple instances.
Declare a static list of config names that should trigger clearCaches() when changed:
class MyService extends BaseService {
static clearCachesConfigs = ['myServiceConfig', 'myOtherConfig']
}This wires up a subscribeToTopic listener automatically β no manual subscription needed.
BaseService implements the LogSupport trait, providing structured logging methods β
logTrace(), logDebug(), logInfo(), logWarn(), logError() β and timed execution blocks via
withTrace(), withDebug(), and withInfo(). See logging.md for full details.
BaseService implements IdentitySupport, providing access to the current user:
| Method | Returns |
|---|---|
getUser() |
Current active HoistUser (accounts for impersonation) |
getUsername() |
Current active username |
getAuthUser() |
Authenticated HoistUser (ignores impersonation) |
getAuthUsername() |
Authenticated username |
These delegate to IdentityService and return null outside a request context.
Services can expose monitoring data to the Admin Console:
Map getAdminStats() {
[
activeConnections: connections.size(),
lastRefresh: lastRefreshDate,
*: configForAdminStats('myServiceConfig') // include relevant config values
]
}Timer, Cache, and CachedValue resources are automatically reported β no need to include them
in getAdminStats().
Avoid: querying the database in getAdminStats(). Stats are gathered on a cluster executor
thread, which has neither a bound Hibernate session nor a request context β a GORM query throws
HibernateException: No Session found for current thread, and the identity accessors return null.
Report state the service already holds in memory. If a stat genuinely must come from the database,
annotate the method @ReadOnly (or wrap its body in withNewSession {}) and keep the query cheap,
since stats are recomputed on every instance each time they are requested.
Override getComparableAdminStats() to declare which stats keys should be compared across cluster
instances:
List<String> getComparableAdminStats() { ['activeConnections'] }The Hoist Admin Console exposes all of this data in the Servers > Objects tab via
ClusterObjectsReport. That tab lists every managed resource (caches, timers, cached values) and
service stat across all cluster instances. For keys declared via getComparableAdminStats(), the
report runs a cross-instance comparison and flags any "breaks" β values that differ between
instances. These breaks also feed into the built-in objectBreaks status monitor provided by
DefaultMonitorDefinitionService, which can alert operators when cluster state has diverged.
BaseService provides getIsPrimary() to check whether the current instance is the primary
(oldest) member of the cluster. This is used internally by primaryOnly timers but can also be
checked directly in service code.
BaseController is the abstract superclass for all Hoist controllers. It provides JSON rendering,
request parsing, async support, identity access, and logging (via LogSupport β see
logging.md).
| Method | Description |
|---|---|
renderJSON(Object o) |
Serialize an object to JSON via Hoist's JSONSerializer and write to response |
parseRequestJSON(Map opts) |
Parse request body as a JSON object (Map). Pass safeEncode: true to OWASP-encode input |
parseRequestJSONArray(Map opts) |
Parse request body as a JSON array (List) |
renderSuccess() |
Render an empty 204 No Content response |
renderClusterJSON(ClusterResult) |
Render a result from a cluster-delegated operation |
runAsync(Closure) |
Execute a closure asynchronously as a Grails Promise |
safeEncode(String) |
Run input through OWASP HTML content encoder |
Avoid using Grails' built-in render or request.getJSON() β always use renderJSON() and
parseRequestJSON() to ensure consistent Jackson-based serialization.
BaseController implements IdentitySupport with the same methods as BaseService β getUser(),
getUsername(), getAuthUser(), getAuthUsername().
Uncaught exceptions are handled by handleException(), which delegates to the framework's
ExceptionHandler. This renders the exception as a JSON error response with an appropriate HTTP
status code.
RestController extends BaseController with a template-method pattern for CRUD operations on
GORM domain classes. It maps standard REST HTTP methods to controller actions and provides hooks
for customization.
Point the controller at a GORM domain class and optionally enable audit tracking:
class PositionController extends RestController {
static restTarget = Position // GORM domain class to manage
static trackChanges = true // log CRUD operations via TrackService
}UrlMappings routes REST requests to RestController actions:
/rest/$controller/$id? β POST=create, GET=read, PUT=update, DELETE=delete
/rest/$controller/bulkUpdate β Apply updates to multiple records
/rest/$controller/bulkDelete β Delete multiple records
/rest/$controller/lookupData β Custom lookup data (controller must implement)
The bulkUpdate action parses a JSON request body {ids: [...], newParams: {...}} and applies
doUpdate() to each record, returning {success: N, fail: N}. The bulkDelete action reads ids
from query/form parameters (via params.list('ids')) and applies doDelete() to each, also
returning success/fail counts. Both continue processing on individual record failures.
Override these methods to customize CRUD behavior:
| Method | Default Behavior | Override When |
|---|---|---|
doCreate(obj, data) |
obj.save(flush: true) |
You need custom creation logic |
doList(query) |
Throws RuntimeException if query is non-empty; otherwise restTargetVal.list() |
You need filtering, sorting, or query support |
doUpdate(obj, data) |
bindData(obj, data); obj.save(flush: true) |
You need custom update logic |
doDelete(obj) |
obj.delete(flush: true) |
You need soft-delete or cascading logic |
preprocessSubmit(Map submit) |
No-op | You need to transform data before create/update |
Note: The default
doList(Map query)throws aRuntimeExceptionwhenqueryis non-empty. It only falls through torestTargetVal.list()when query is empty/falsy. Any controller that needs to support query parameters must overridedoList.
class PositionController extends RestController {
static restTarget = Position
@Override
protected List doList(Map query) {
def fund = query.fund
fund ? Position.findAllByFund(fund) : Position.list()
}
@Override
protected void doCreate(Object obj, Object data) {
obj.createdBy = username
obj.save(flush: true)
}
}When trackChanges = true, every create, update, and delete is logged via TrackService with the
Audit category. Override trackChange() to customize the tracking behavior.
Services are Spring-managed singletons. Within services and controllers, access other services via Grails dependency injection:
class PortfolioService extends BaseService {
def configService // injected by Grails
def marketDataService
}Outside of Spring-managed classes (POGOs, domain objects, utility classes), services are not
injected. Instead, use the static typed accessors on io.xh.hoist.util.Utils:
import static io.xh.hoist.util.Utils.getConfigService
class PortfolioSummary {
Map compute() {
def threshold = configService.getDouble('portfolioThreshold')
// ...
}
}Hoist's Utils provides typed accessors for core framework services (configService,
prefService, identityService, etc.). Applications can create their own Utils class following
the same pattern to expose app-level services:
class Utils {
static PortfolioService getPortfolioService() {
return (PortfolioService) io.xh.hoist.util.Utils.appContext.portfolioService
}
}The getOrCreate lazy-computation pattern and the timer-driven refresh pattern (a primaryOnly
timer populating a replicated cache) are documented in caching.md, along with the
use of Timer.forceRun() to repopulate a cache from within clearCaches().
The BaseController.renderJSON() and parseRequestJSON() methods form the bridge between server
and client. Client-side hoist-react code communicates with controllers via FetchService, which
expects JSON request and response bodies following Hoist conventions.
RestController endpoints (/rest/$controller) are consumed by hoist-react's RestGridModel and
RestStore, which map grid CRUD operations to the standard REST actions.
See json-handling.md for details on JSON serialization conventions.
When overriding clearCaches(), always call super.clearCaches() to ensure the framework updates
its internal tracking (e.g. lastCachesCleared timestamp):
// β
Do: Call super
void clearCaches() {
super.clearCaches()
myCustomState = null
}
// β Don't: Skip super
void clearCaches() {
myCustomState = null
}All resources created via factory methods (createCache, createCachedValue, createTimer,
createIMap, createISet) share a single namespace within each service. Using the same name for a
cache and a timer will throw a RuntimeException at startup.
Pitfalls specific to Cache and CachedValue β synchronous vs. asynchronous onChange delivery,
replicate silently changing handler timing, caching null, and inexact size() β are covered in
caching.md.
Admin stats run on a cluster executor thread with no Hibernate session, so any GORM query fails there. Report what the service already holds:
// β
Do: read in-memory state
Map getAdminStats() {
[tradeCount: tradeCache.size(), lastRefresh: lastRefreshDate]
}
// β Don't: query from the stats method
Map getAdminStats() {
[tradeCount: Trade.count()] // HibernateException: No Session found for current thread
}See Non-Request Threads and GORM Sessions for the general rule and the other contexts it covers.
Grails' built-in render method uses a different JSON converter. Always use renderJSON() for JSON
responses to ensure consistent Jackson-based serialization with support for JSONFormat and custom
serializers.
The exception is file and binary responses β use Grails render directly when returning
non-JSON content such as Excel exports, PDFs, or log file downloads:
render(file: fileBytes, fileName: 'export.xlsx', contentType: 'application/octet-stream')Service init() runs during application startup with a default timeout of 30 seconds. If a service
needs to load data that takes time, consider whether it truly needs to be ready before the app
starts serving requests:
- If it does β that's fine, leave the work in
init(). If it regularly exceeds the default timeout, increase it via thetimeoutparameter toparallelInit()orinitialize(). - If it doesn't β create a timer without
runImmediatelyAndBlock. The timer will kick off its first run right away but asynchronously, allowinginit()to return and app startup to continue. Usedelay: trueif you want to wait one full interval before the first run.