What a Seasoned Java Developer Would Not Expect¶
Tentackle looks familiar from a distance — entities, transactions, a desktop client, Maven plugins. Up close, it deviates from mainstream Java frameworks (Spring, JPA/Hibernate, Jakarta EE) in ways that routinely surprise experienced developers. This document is the distilled list of those deviations, each with a pointer to the document that covers it in depth.
The programming model¶
- Entities are interfaces, and
newdoes not compile. Every PDO, operation, and most framework components are interfaces; implementations are located via the build-time service registry and instantiated as dynamic proxies:Invoice invoice = on(Invoice.class). - Multiple inheritance, emulated. A PDO weaves
two separate implementation classes — one for persistence, one for domain logic — into a single
object via a proxy. The two concerns never compete for the one
extendsslot, and neither layer knows the other. - The entity model lives in a comment block. Attributes, relations, indexes, and validations are declared in a DSL embedded as a comment in the PDO interface source — a single definition from which the persistence interfaces and implementations, the DDL, and the database migrations are all generated.
- Generated code is woven into your sources, inside guarded folding regions, by the
Wurbelizer — not dumped into
target/generated-sources. You read and debug one file per class. - Rich domain objects, not anemic ones. Because a PDO is never in a degraded state, behavior
lives on the entity (
invoice.approve()), not in transaction-script services — see PDOs vs. Traditional ORMs.
No ORM lifecycle¶
- There is no
LazyInitializationException— the exception type does not exist. A PDO carries its session and context inside itself and can navigate any relation at any time, in any JVM, no matter how many tiers separate it from the database. - No entity states. No managed/detached/removed, no persistence context, no
merge(), no fetch plans, no Open-Session-In-View, and no DTO layer — the PDO itself travels between JVMs and stays fully functional on arrival. - No identity map. A session holds no references to the PDOs loaded through it, so objects are
garbage-collected the moment your code drops them — batch loops never need
em.clear(). Shared identity is an opt-in, per-entity choice via the PdoCache. - Explicit saves. There is no automatic dirty-flush; you call
persist()where you mean it. Modification is still tracked per attribute — the write is just where you put it. - Transactions demarcate the database, not your objects. A
@Transactioncommits or rolls back the unit of work; object usability is never tied to its boundary.
Aggregates are enforced, not a convention¶
- The persistence layer knows root entities and components. Each component row carries its root's id and type (rootId/rootClassId), so a component knows its aggregate root without a query.
- A component cannot be persisted on its own. The "set the foreign key and save the child" path that every ORM allows simply has no API; the only door into the database is the root, and persisting the root validates the entire aggregate first — including cross-entity invariants.
- A component loaded outside its root context is made immutable, and authorization is aggregate-scoped: if you may not read the root, its components behave as if they did not exist.
- Undo is aggregate-scoped too. A snapshot reverts root attributes, component lists, and modification flags atomically — and a stale snapshot is rejected instead of silently misapplied.
Location transparency and the multi-tier cascade¶
- Every JVM is a node, and a server is itself a client. Nodes stack into a cascade of arbitrary depth — client → edge proxy → core server → database — and the same artifact runs unchanged at any position. Whether a session is local JDBC or remote is decided solely by a connection URL; adding or removing a tier is configuration, never code.
- Caches stack per tier. Reads trickle up until a tier's PdoCache hits; invalidations trickle down via database-backed serial counters — a coherent multi-level cache hierarchy with surgical expiry, no message broker required (modification tracking). CQRS-style read/write splitting falls out of the cascade as a deployment choice.
- Cursors work remotely. A
ResultSetCursorstreams a JDBCResultSetheld on the server while a client three tiers away drives it — the batch code cannot tell the difference. - The change tracker is deliberately a non-daemon thread. If it dies, the application dies with it: an app that has silently lost its cache-coherency notifications would be wrong quietly, and Tentackle would rather stop.
A wire protocol of its own¶
- TRIP replaces RMI and Java serialization
with a compact binary format: a type dictionary transmitted once per client, variable-length
encoding, deduplication, and sparse-collection optimizations. Remote interfaces are plain Java
interfaces — no
throws RemoteException, no export/unexport ceremony. - SD-WAN and NAT friendly. Unlike RMI, TRIP never sends hosts, IPs, or ports back to the client, so gateways and relays just work — and each new remote proxy costs no extra round-trip.
- Transports are picked per link by URL scheme: plain TCP, TLS, deflate compression, pre-shared-key encryption (for links without a PKI), and QUIC/RFC-9000 — mixed freely within one cascade.
- Version skew fails at connect time, not mid-stream. Built-in client/server version checking and an evolution-tolerant type dictionary turn incompatibility into a clear, immediate error.
Correctness is always on¶
The framework's first commitment is fail loudly and early rather than corrupt quietly:
- Optimistic locking cannot be turned off. Every write carries the
serialthe object had when read; a stale write matches zero rows and is rejected — from a form, a batch, or an admin tool alike (locking). An opt-in, auto-expiring token lock adds early conflict warning on top. - A
@Transactiontransparently retries a whole unit of work when the failure was temporary — an optimistic-lock conflict, a deadlock, a serialization failure. - Validation travels with the object.
Declared on the member, scope-aware (persisting vs. interactive), severity-aware, executed before
every persist regardless of code path — and a
ValidationResultsurvives a TRIP round-trip with a path (invoice.lineList[3].price) that a client can map back to the exact widget. - Annotations take dynamic parameters.
condition = "$isPrinted"or a Groovy expression — CompoundValue evaluates strings into constants, property references, or scripts at runtime, side-stepping Java's compile-time-constant limitation. - Objects can be switched read-only at runtime. The
Immutable contract guards every generated
setter; cached PDOs are shared across threads finally immutable, and freezing cascades along
aggregate boundaries. Freezable extends
the same idea to mutable JDK value types like
java.util.Date. - Interceptors are compile-time
verified and deterministically ordered — a misplaced
@Transactionor@Securedis a compilation error, not a runtime surprise.
Everything resolvable at build time is resolved at build time¶
- No classpath scanning, ever. Service discovery is written by the compiler into
META-INFtext files and looked up from maps at runtime (services). Annotation parameters are captured as data too, so resolving "class id 2001 ⇄Message" loads no classes at all — startup time does not grow with the model. - The same SPI works on the classpath and the module path. Every one of the 37 reactor modules is a real JPMS module, yet applications run modular, non-modular, or mixed.
- The database schema is derived, migrated, and verified from the model. The SQL plugin generates backend-specific DDL, computes incremental migrations by comparing the model against a live database's metadata, and can fail the build if the two drift.
- Even resource bundles and validation annotations are build-checked by the check plugin.
A full-stack JavaFX framework¶
Tentackle is one of the very few frameworks that cover a JavaFX application full-stack — the same programming model reaches from the SQL database through the middle tiers into the desktop UI. The JavaFX ecosystem offers control libraries and themes, but almost nothing that binds a UI to a persistence layer, a security model, i18n, and a deployment story in one piece:
- An extended FX layer, not a control kit. Zero per-widget glue. Every wrapped control is a first-class, bindable
component carrying a model type, a value translator, and mandatory/changeable/error state.
Controllers are discovered via the SPI, their FXML/CSS/bundles located by convention, and
components bound to model members by name — in the model's own types (
BigDecimal,LocalDate, an enum, a PDO), never as raw strings (tentackle-fx). - FXML, CSS, and
.propertieslive insrc/main/java, co-located with their controllers — copied as resources during the build, so SceneBuilder finds them by relative path. - The rich desktop client is generated from the model. The
RDC layer bundles everything the UI needs per
entity behind one
GuiProvider: default editors, finders, context menus, and tables whose columns are binding paths — with layouts that roam with the user via database-backed preferences, plus printing and Excel export. - Validation reaches the widget. A server-side
ValidationResultmaps back to the exact control that caused it (InteractiveError) — authoritative checks on the server, precise feedback on the screen.
The deployment story is equally unusual. The jlink/jpackage plugin builds self-contained images (and native installers with menu entries and icons) — and it differs from other jlink plugins in ways that matter:
- It packages any Java application — modular, non-modular, or mixed. It debunks the myth that
jlink requires full modularization: it analyzes all dependencies (reading
module-info, falling back tojdeps) and derives the packaging category itself — real modules go into the trimmed runtime image, automatic modules land next to it on a module path, classpath jars on a classpath. Nothing about your dependency graph has to change first. - The generated start script is the other half of the image. Instead of jlink's built-in launcher (which cannot pass a module path or classpath at all), the plugin materializes its dependency analysis into a platform-specific launch script generated from editable Freemarker templates — keeping platform details out of the POMs, which stay minimal because the Maven model already provides most of the configuration.
- Self-update is built in. A per-user image can replace itself in place via the update service: the server alone dictates the target version, the download URL, and the update script, so bulk downloads can be steered to a CDN or local mirror instead of through the application server — the successor to the retired Java Web Start.
Batteries a mainstream stack does not include¶
- Authorization rules are data, not code. The
security subsystem stores ACLs as ordinary
PDOs, editable at runtime through a UI, and enforces them inside
select/persist/delete— a row you may not read is never handed to you. - Translations live in the database. tentackle-i18n transparently intercepts bundle loading; translators edit texts live with BundleMonkey — effective immediately in every running JVM, including locales the developers never shipped.
- Preferences roam. A
java.util.prefs-style API backed by the database, so window geometry and table layouts follow the user to any workstation. - Business numbers are pooled, transactionally. Number sources hand out invoice/order numbers from persistent pools — and take them back.
- Secrets are never casually readable. A single application-specific Cryptor encrypts passwords in filtered resources at build time, in properties files, in memory, and on the wire.
The trade, stated honestly¶
None of this is free: more exceptions by design, explicit saves, a cohesive but smaller ecosystem, and machinery you don't need if you control your topology and run a conventional web stack. Where Tentackle earns its keep is spelled out in Why Tentackle Fits Technical and Scientific Applications — and where a mainstream framework is the better fit is stated there too.
Related Documentation¶
- Correctness First — the design philosophy behind the always-on guarantees.
- PDOs vs. Traditional ORMs — the lifecycle comparison in depth.
- Multi-Tier Cascade — nested servers, stacked caches, and CQRS.
- TRIP — the wire protocol.
- Service and Configuration API — build-time service resolution.