Skip to content

Server Types and Instantiation

Kevin Herron edited this page Oct 12, 2026 · 11 revisions

Milo 1.2.0-RC1, Java 17. API baseline 69cb25087804.

The tutorial server builds Thermostat one node at a time. That works for one object, but a server with many objects of the same kind, or one that exposes a standard information model, creates each object from a type instead. The type declares the members once, and Milo copies them into a new instance with the identities you choose.

The alarm builder in Alarms and Conditions already used this engine to instantiate the alarm type. Here you call it directly, from a managed namespace like the one in Address space.

How instantiation works

A type is an ObjectType or VariableType plus its declaration hierarchy. Supertypes contribute inherited declarations, and HasModellingRule marks each member as Mandatory, Optional, or a placeholder. Load or construct the type and its declarations first. This API does not import NodeSet2 XML, and a generated Java node class represents model attributes and references, not an application driver.

An instantiation request names the type, the root's identity and parent, the node manager that stores the result, and any choices about optional members and NodeIds. Milo compiles the type into a structural model, plans every node and reference the instance needs, and then stores them. The result records exactly what it created, so you also use it to delete the instance later.

Create a Devices folder

Obtain NodeInstantiator from server.getNodeInstantiator(). This fragment creates a FolderType Object named Devices under Objects, where a server with many thermostats could group them. nodeManager belongs to a started managed namespace, and folderId and folderName use that namespace's registered index. In the tutorial namespace those would come from getNodeManager(), newNodeId("Devices"), and newQualifiedName("Devices"). Imports:

  • InstantiationRequest and InstantiationResult from org.eclipse.milo.opcua.sdk.server.nodes.instantiation
  • UaObjectNode from org.eclipse.milo.opcua.sdk.server.nodes
  • NodeIds from org.eclipse.milo.opcua.stack.core
  • LocalizedText from org.eclipse.milo.opcua.stack.core.types.builtin
InstantiationRequest<UaObjectNode> request =
    InstantiationRequest.of(UaObjectNode.class, NodeIds.FolderType)
        .nodeId(folderId)
        .browseName(folderName)
        .displayName(LocalizedText.english("Devices"))
        .parent(NodeIds.ObjectsFolder, NodeIds.Organizes)
        .target(nodeManager)
        .build();
InstantiationResult<UaObjectNode> result = server.getNodeInstantiator().instantiate(request);

InstantiationRequest.of(...) names the type and the Java class you expect for the root, which Milo checks while planning. When instantiate returns, the result's typed root() is already stored with its planned references, so clients can browse to the Devices folder and read its DisplayName. The default conflict policy rejects a NodeId that already exists. Keep result, because it is how you remove this instance later.

Select members and identities

The Devices folder needs no member choices, but richer types do. You make them in the request, before any node is created.

Requirement Request API and responsibility
Include a specific optional includeOptional(BrowsePath) takes a full QualifiedName path. Nested selection includes needed ancestors.
Include all optionals includeAllOptionals() includes optional Methods too. Confirm the resulting public shape.
Exclude an optional subtree excludeOptional(BrowsePath) prunes its subtree. Mandatory members cannot be omitted.
Pin an identity assignNodeId(path, id) overrides strategy and default allocation.
Derive identities nodeIdStrategy(...) receives root, occurrence path, and declaration. Produced IDs are collision-checked.
Realize placeholders expandPlaceholder(...) describes concrete occurrences. forPlaceholder(...) creates an additional realization against an existing instance. Choose supported concrete subtypes for abstract declarations.
Configure behavior onNode(...) attaches filters and initial state. bindMethod(...) installs Method handlers.

Use org.eclipse.milo.opcua.sdk.server.nodes.instantiation.BrowsePath, not the deprecated factory class with the same name. Placeholder occurrences need concrete browse names and unique NodeIds, which you supply, because Milo does not guess them from device inventory.

Methods are copied by default. With shared realization, bind per Object through MethodBindings. Include argument metadata and optional Methods deliberately, since they become part of the instance's public shape.

Plan before applying

instantiate(request) plans and applies in one call, and you can also run the steps separately. describe(typeId) returns a compiled structural model, plan(request) resolves selections, identities, collisions, and references without constructing nodes, and apply(plan) materializes the plan. Planning first lets you inspect an instance and its diagnostics before anything changes in the address space. Compilation reports structural problems as diagnostics instead of silently truncating inheritance.

Plans and cached models are snapshots. Coordinate model edits, and invalidate the model cache before expecting a changed declaration tree. A plan made against an older model fails when applied.

Hooks run inside instantiation's staged workflow. Put declarative identity and attribute choices in the request, and use hooks for behavior. Do not rely on rollback to undo arbitrary external side effects a hook causes, because rollback removes only the nodes and references the instantiation recorded.

The result reports diagnostics and the storage guarantee achieved. Built-in managers support atomic storage primitives. Custom managers using emulated primitives give best-effort behavior and require appropriate writer coordination.

When things go wrong

What you see Meaning and response
Error diagnostics on a plan plan(...) reports request problems such as NodeId collisions, invalid classes, or omitted Mandatory members on the plan instead of throwing, and apply(...) refuses such a plan. Choose another identity for a collision, or pin one with assignNodeId(...).
InstantiationException from instantiate(...) or apply(...) The plan had errors or an apply stage failed. The exception names the phase and carries per-path diagnostics. A failed apply removes what it added, except when the rollback itself fails, which carries its own diagnostics, or when a Method binder already changed a reused or shared node.
UaException from describe(...) or plan(...) The type's model cannot be compiled. Fix the type definition or its declarations.
Members you expected are missing Inspect the result's skipped declarations and diagnostics instead of assuming the whole type was realized. Optional members need explicit selection.

Known limitations

The nodes.instantiation package is experimental for one minor release, so its API can still change. Check the 1.2 migration guide before changing a persisted model.

Cleanup and ownership

Call result.deleteCreated() when this model instance is retired. The result owns only its recorded additions, so it does not delete reused nodes or replacements installed later under the same identifiers. Cleanup and rollback check object identity to leave those replacements in place.

Keep application resources, Method binding lifetimes, and Condition registrations outside node-storage ownership. Stop those users before deleteCreated(), because cleanup removes nodes but does not terminate application threads.

Migrating NodeFactory users

NodeFactory and the legacy factory package remain available, with frozen behavior, during their deprecation period.

Audit every persisted root and member NodeId before switching. The new default derivation escapes backslashes, slashes, and colons in names and String roots. It marks non-String roots and String roots that mimic type markers. These rules can change descendant identities even when the root is unchanged. legacyPathStrings() preserves the old unescaped path-string formula for unpinned, default-derived members. Handle existing pinned identities with assignNodeId(...) or a deliberate strategy.

The new engine copies MinimumSamplingInterval, Historizing, AccessLevelEx, RolePermissions, UserRolePermissions, and AccessRestrictions from declarations. In several paths, the legacy factory left these unset or unrestricted. An existing declaration can therefore change instance sampling or access behavior after migration, so re-test Reads, Writes, Calls, Browse, and subscriptions for every intended role and channel policy.

Map each legacy pattern to its request equivalent:

  • Replace post-creation identity changes with request identity and parent placement.
  • Replace optional callbacks with path selection, and post-creation deletion with request-time exclusion.
  • Compare optional Method shape and placeholder realization, not just the root's class.
  • Retain the new result and clean up with deleteCreated() instead of guessing which nodes were created.

Migrate a test model first, and verify client browsing, calls, persisted identifiers, and cleanup. The 1.2 migration guide records additional behavior changes.

Examples and reference

Next steps

Instances give the server its structure, and their Variables still need values. To control how often the server samples those values for subscribed clients, and to read a device in batches, continue with Sampling.

Related: Server SDK · Address space · Events · Methods

Clone this wiki locally