- Migration Guide
- Migrating from 1.5.378 to 1.6.x
- Source Generation
- Improved Type safety
- Several built in types are now immutable value types
- ByteString
- ArrayOf and MatrixOf
- DateTimeUtc
- QualifiedName and LocalizedText
- StatusCode
- NodeId/ExpandedNodeId
- Variant, DataValue and ExtensionObject
- XmlElement
- EnumValue to represent the enumeration built in type
- Other Data Types
- Obsoleted APIs and replacements
- APIs permanently removed
- Encoders and Decoders
- Node States
- User Identity Token Handlers
- Serialization and Configuration
- NodeState Cloning and Lifecycle
- Encodeable Factory and Type System
- Complex Types
- Session and Browser State Persistence
- Other Breaking Changes
- GDS Client API modernization
- Migrating from 1.05.377 to 1.05.378
- Migrating from 1.04 to 1.05
- Support
- Migrating from 1.5.378 to 1.6.x
This document outlines the breaking changes introduced from version to version. General principles we follow:
- All API that is replaced with newer API is marked as [Obsolete] and code should compile and work albeit of the warnings which can be suppressed. [Obsolete] API will be cleaned up in the next "minor" version increment. Therefore we recommend to upgrade from minor version to minor version and fixing all [Obsolete] warnings as you go along.
- API that "cannot" be supported anymore will be removed in a minor version and migration steps documented below. We are trying to keep this to an absolute minimum.
- Bugs or issues found in Obsoleted API are not supported.
- We now follow semver, but do not use the major version indicator to denote breaking changes like (1) or (2) as we should if we followed related conventions. We are a small team and cannot afford to maintain previous major versions, therefore we are trying to keep cases of (2) to a minimum and expect you to upgrade to the next minor version within 6 months of release.
Pro TIP: Point your favorite coding agent at this doc and let them take care of the migration work!
Version 1.6 introduces a major architectural change from pre-generated code files to runtime source generation and more efficient memory use with a several major Breaking Changes requiring changes to your applications.
Instead of generating code for OPC UA design files using the ModelCompiler, this version of the stack uses Source Generators to generate code behind for your project. Input into the source generator can be NodeSet2.xml files or ModelDesign.xml files (the same that ModelCompiler consumes). Source generators are Roslyn analyzers, that are called by the Roslyn compiler and emit code during the build process.
Model compiler generated csharp code is not supported in this version!
To migrate remove all your generated files (ending in *.Classes.cs, *.Constants.cs, etc.) and only leave the design file(s) (.xml and .csv files) in your project. Add an entry into your csproj file similar to the following to provide the location of the design files to the source generation process:
<PropertyGroup>
<!-- Optional: to configure whether to allow sub types - see model compiler documentation -->
<ModelSourceGeneratorUseAllowSubtypes>true</ModelSourceGeneratorUseAllowSubtypes>
</PropertyGroup>
<ItemGroup>
<!-- Generate code behind for the following design or nodeset2.xml files during build-->
<AdditionalFiles Include="Boiler\Generated\BoilerDesign.csv" />
<AdditionalFiles Include="Boiler\Generated\BoilerDesign.xml" />
<AdditionalFiles Include="MemoryBuffer\Generated\MemoryBufferDesign.csv" />
<AdditionalFiles Include="MemoryBuffer\Generated\MemoryBufferDesign.xml" />
<AdditionalFiles Include="TestData\Generated\TestDataDesign.csv" />
<AdditionalFiles Include="TestData\Generated\TestDataDesign.xml" />
</ItemGroup>The source generator model has several benefits that go beyond custom msbuild targets: Among the most important is that the generator ships with the stack and therefore code that is generated conforms to the stack version that ships the analyzer (the source generator will be part of Opc.Ua.Core nuget package). Therefore when updating to a newer version the code generated automatically takes advantage of the improvements made across the entire stack.
Code generation during compilation also allows not just emitting code ahead of time, but also to generate code while you are developing. We now take advantage of this feature to generate IEncodeable implementations for partial POCO types on the fly using the [DataType] and [DataTypeField] attributes as annotation (similar to DataContract/DataMember).
The stack itself uses source generators to generate the core opc ua code. Therefore all pre-generated code files (Generated/ folders) have been removed and are now generated at build time. As a result of using source generators to generate the stack code all *.nodeset2.xml files previously included as embedded zip have been removed. Also, all *.Types.xsd and *.Types.bsd files are now included as string resource instead of embedded resources. If you need access to these, use the new Schemas.XmlAsStream and Schemas.BinaryAsStream APIs in the node manager namespace which produce a utf8 stream. Alternatively you can use the existing ModelCompiler tool to generate these files.
When you encounter slower build times use incremental compilation and avoid changes to code in Opc.Ua and Opc.Ua.Core project. In addition you can change your builds to only build for your target framework using the dotnet -f <tfm> command line option.
New Opc.Ua project as an intermediate project. Impact:
- Most applications using NuGet packages are not affected. Continue linking to Opc.Ua.Core project as it includes the Opc.Ua intermediate assembly
- Assembly loading order may change
The Variant and TypeInfo, NodeId, ExpandedNodeId, ExtensionObject, LocalizedText and QualifiedName are now readonly structs. This is a large breaking change and affects existing usage:
- You cannot compare any of these types against
null. Use the instance properties:NodeId.IsNull,ExpandedNodeId.IsNull,QualifiedName.IsNull,LocalizedText.IsNullOrEmpty,ExtensionObject.IsNull. In case ofArrayOf/MatrixOf/ByteString, you can most often just check againstIsEmptywhich checks null and emptiness. - The default item can be created by assigning
default, e.g. producingNodeId.Nullfor NodeId andQualifiedName.Nullfor QualifiedName. It is recommended to use theNullproperty on these types for readability and per your coding conventions. - Any API that mutated an instance of one of these built in types must be replaced with methods that return a new value of the type, e.g.
NodeId.WithNamespaceIndex(ushort)as setters were removed.
Previously the OPC UA built-in type ByteString was represented as byte[]. This caused ambiguities with regards to it and the byte array type. This has changed and ByteString is now a type in the Opc.Ua namespace. It is a wrapper around ReadOnlyMemory<byte> and while Variant handles both still interchangeably, the generated API now simplifies mixing of byte arrays and ByteString without confusion.
Note that equality operation compare the content of the byte string. A ByteString is a value type while System.Byte[] is not. It cannot be compared against null. However, it supports checking for empty IsEmpty and IsNull whereby the first checks whether the ByteString is effectively a ByteString.Empty amd the second checks whether ByteString was initialized using default.
While it was tempting to make ByteString implicitly convertible from byte[], an explicit cast is needed to strictly distinguish against ArrayOf<byte> which implicit converts to byte[]. Prefer the ByteString.From or ToByteString() calls to cast operators to make your code's intentions explicit. Note that a byte[] implicitly converts to ReadOnlyMemory<byte> in .net therefore any conversion from ByteString is explicit.
To migrate, perform the following general replacements in your code:
Change code as follows:
- Replace
byte[]withByteStringin areas flagged as errors, e.g. wherever casting aVariantto abyte[]change it toByteStringor toArrayOf<byte>if it is a byte array. - When a
ByteStringis required as input and you have any form of enumerable bytes, try appending.ToByteString()to convert. - Use
ByteString.Combinein lieu ofUtils.Append. - Indexing and enumeration of bytes is only supported via the
Spanproperty. Change your code to replace[i]with.Span[i]to fix errors. - If your code tried to set a byte in the ByteString, create a buffer
byte[]and after changing convert toByteStringusingByteString.From(buffer)or.ToByteString()extension method - Perform changes only where you encounter build breaks. This should be enough to get into a working state. Later adjust the code as needed.
Similar to ByteString, ArrayOf<T> and MatrixOf<T> are new type safe and sliceable generic value types representing non-scalar values. They are immutable meaning the values at an index inside them cannot be "set" unless they are converted to a Span<T> (and then reconverted to a ArrayOf/MatrixOf).
In addition to slicing and range based access, both types provide the ability to apply a NumericIndex to them. They are efficiently stored inside a Variant as well and can be used to allocate efficiently from ArrayPool providing the ability to built object pooling support at the array level. ArrayOf<T> implicitly converts to List<T> but not vice versa. For API that is taking ArrayOf<T> as input convert any list using ToArrayOf. IsEmpty returns true if IsNull is true but not necessarily vice versa.
Internally an ArrayOf/MatrixOf stores a reference to "memory" and a offset and length integer. They have the same layout as ReadOnlyMemory<T> although this is not guaranteed to stay so in the future. All generated collection types implicitly convert to and from ArrayOf<T> whereby T is the member type of the collection type. E.g. VariantCollection is effectively ArrayOf<Variant>.
ArrayOf<T> provides helper methods e.g. to AddItem an item or AddItems of items in another ArrayOf<T>. Both return a new ArrayOf<T>, very similar to the .net ImmutableCollection classes or the Append or Concat extension methods in the System.Linq.
Contains, IndexOf, Filter, Find, FindIndex and ConvertAll methods mimic the Linq Where, Any, FirstOrDefault, Select or the respective methods on the List<T> type. Use SafeSlice instead of Take to slice up to the length and which returns an empty array instead of throwing which is what the regular Slice/range operators do. You cannot use more advanced Linq expressions (e.g. order by or group by) without converting to a list (ToList) or array (ToArray) first. Linq is slow, so using the methods on the array type where possible will provide a performance improvement.
All generated APIs, Encoders/decoders, and the Variant type now use ArrayOf/MatrixOf instead of the previously generated/built-in non-generic collection types which have been removed.
Note that equality operators and methods now compare the content of the Array and Matrix, not just reference equality as with T[]. It supports checking for an empty array or matrix via IsEmpty and IsNull whereby the first checks whether the array is effectively a ArrayOf.Empty<T> amd the second is just a check against ArrayOf<T> initialized using default (since it is not a reference type anymore). IsEmpty returns true if IsNull is true but not necessarily vice versa.
Change code as follows:
- Replace any
T[]withArrayOf<T>where T is the type of the element in the array. Do this where errors are flagged, e.g. wherever casting a Variant to aT[]change it toArrayOf<T>if it is a T array. - Change all use of
<Type>CollectionorIList<Type>toList<Type>(add ausing System.Collections.Genericdirective if needed). When the collection is never mutated (items added, inserted or removed), useArrayOf<Type>. - In case of
error CS4007: Instance of type 'System.ReadOnlySpan<T>.Enumerator' cannot be preserved across 'await' or 'yield' boundaryconvert the enumeratedArrayOf<T>to a list usingToList()and enumerate the list. - When trying to set a value in the previous array, create a buffer
T[]and after mutating convert toArrayOf<T>usingbuffer.ToArrayOf(). - To add items to an
ArrayOfuse the newAddItem/AddItemsmethods where you would have usedAddorAddRangebefore. Note that ArrayOf is immutable so the result needs to be assigned to the variable to which you want to add. You can also use the+=operator for less verbose code. - In performance intensive code or where items are added in a loop it is best to first create a
List<T>and then assign the list later (e.g. after the loop) to a variable ofArrayOf<T>type. - Perform changes only where you encounter build breaks. This should be enough to get into a working state. Later adjust the code if needed.
- Remove any use of
Matrixwhich is deprecated and replace withMatrixOf<T>which is type safe.
// Some examples
VariantCollection c = new VariantCollection();
// if (c != null) if c is passed from outside
c.Add(new Variant(1))
var first = c.FirstOrDefault();
Int32Collection i = c.Select(v => (int)v).ToList();
// need to change to
ArrayOf<Variant> c = [new Variant(1)]; // or
ArrayOf<Variant> c = default; c = c.Add(new Variant(1)); // or
ArrayOf<Variant> c = default; c += new Variant(1);
var first = !c.IsEmpty ? c[0] : default;
ArrayOf<int> i = c.ConvertAll(v => (int)v);Previously the DateTime built in type was represented by the System.DateTime type. It is now represented by the Opc.Ua.DateTimeUtc type. This new type complies with the details of the spec without requiring external helper methods to be used. It's Value property returns the ticks, bounded by the information in Part 6 of the spec, and its time is always UTC. There are conversion operations to and from DateTime, but also DateTimeOffset and long and a minimal subset of System.DateTime API to allow for simpler porting. DateTime implicitly converts to DateTimeUtc, but not vice versa to force use of the new type.
Change code as follows:
- Replace
DateTimewithDateTimeUtcwhere appropriate, especially in places where comparing withDateTime.MinValue. - Replace
DateTime.UtcNowwithDateTimeUtc.Nowfor UTC time "right now".DateTime.NoworDateTime.Todaycan be cast or replaced with its Utc variant, which is likely intended anyway as all date/time values in OPC UA are UTC. - When assigning a
DateTimevalue to aDateTimeUtcvariable, add a cast, or use the correspondingDateTimeUtcconstructor.
There is no implicit conversion from string to QualifiedName or LocalizedText anymore. For one, it flags areas where null assignment is happening implicitly, and secondly, it makes the API more explicit. E.g. previously it was possible to assign a string to a browse name which landed the browse name accidentally in namespace 0 instead of the owning namespace. If you know what you are doing you can explicitly cast the string, but it is suggested to use the new static From API instead.
StatusCode contains now not only a uint code, but also a symbol. Symbols are interned strings and using the StatusCodes constants therefore come with the symbol string. This removes the need to look up the symbolic id, however, when receiving a uint code it needs to be translated to a StatusCode constant to retain the Symbol. Older API has been obsoleted with proper instructions. Since types are immutable it is important to replace mutation calls with the proper replacement method and store the returned value.
NodeIds with integer identifiers (the most common case) now do not box the integer identifier anymore into an object, making the entire NodeId heap allocation free (*). ExpandedNodeId with integer identifiers only contain an allocated namespace Uri, which is mostly a const (interned) string, reducing small allocations across both types. Because both types are now immutable, they must be mutated using the provided With<X>. Access to the identifier in boxed form (object) is deprecated. Instead use the TryGetIdentifier(out uint/string/Guid/byte[]) API. If you need to get the identifier only to "stringify" it, use the IdentifierAsText property which avoids boxing integer identifiers.
There is no implicit conversion from uint/Guid/string/byte[] to NodeId/ExpandedNodeId to ensure assignment of null reference types (byte array and string) is not happening implicitly and to prevent accidental conversion of these identifiers into namespace 0. It also removes hidden behavior such as parsing during assignments and flags areas where a proper Null/default NodeId should be inserted/returned. Use the explicit cast (e.g. (NodeId)[(byte)3, 2]) instead. For the previous implicit conversion from string to NodeId conversion use NodeId.Parse and ExpandedNodeId.Parse. On the same note, the constructor taking a string and no namespace index has been deprecated as it required a string to parse. Use Parse/TryParse instead.
(*) Note that NodeId leverages the new
uintfield to cache the HashCode of a "non-uint" "Identifier", which provides faster lookup using NodeId/ExpandedNodeId as key.
Previously the Variant was a mutable struct containing a TypeInfo and Value property allowing setting the inner state and returning object. All value types thus were implicitly boxed to object and landing on the heap. The new Variant only boxes value types > 8 bytes in size (*), and stores the rest in a union. TypeInfo, previously a class, also now is stored as a 4 byte type (with padding).
The ExtensionObject was a reference type wrapping a NodeId and a body as a reference type of object. The ExtensionObject is now an immutable value type with type-safe access to its body.
Access to the Value property of Variant is marked as [Obsolete] to discourage use in favor of casting to <Type> or Get<Type>() (both throw) or preferably bool TryGet(out <Type> value) calls. The same applies to the Value property of DataValue. The APIs perform any required conversion between BuiltInType.Int32 and BuiltInType.Enumeration as well as arrays of BuiltInType.Byte and BuiltInType.ByteString. This also applies to the Body property of ExtensionObject. Here prefer the use of TryGetEncodeable<T> and TryGetBinary, TryGetJson, TryGetXml.
Creating a Variant or ExtensionObject via the constructor taking a object parameter is also marked [Obsolete] to encourage using type safe API to create a Variant (and thus not storing the wrong value in the inner object variable that cannot be converted out again or makes the Variant a null variant unexpectedly).
In some cases it is desirable to gain access to what was returned from the now obsoleted Value property. To make the fact that the returned value is likely boxed, the new API is named AsBoxedObject(). While the Variant has conversion operators from all supported types and corresponding From(<Type> value) APIs, it is sometimes necessary to convert from System.Object. Note that AsBoxedObject() does not return .net array types but ArrayOf<T>, and ByteString for - yes - ByteString. Value property converts to the old style type expectations.
To perform conversion from <T> to a Variant, helper methods are available in VariantHelper static class. These helper methods are split into ones that use reflection and ones that do not. Overall, use of these helper methods is not recommended in favor of switching on the type information in the Variant.
DateTimeUtcandEnumValueare always stored unboxed inside a Variant. However, converting a enum (System.Enum) to an EnumValue requires boxing on .net standard and .net framework. All other built in value types (ExtensionObject,NodeId,QualifiedName,LocalizedText,Uuid, etc.) are > 8 bytes in size and are therefore boxed when stored inside a Variant. Future improvements will make certain types likeArrayOfbe stored spliced inside the Variant (where the array pointer is stored in the object, and length/offset inside the union).
Variant is now the type reflecting the OPC UA Variant type in all API. That means all generated API now uses Variant instead of System.Object and all Value Properties are Variant too. This provides type safety and removes the need for Reflection via GetType() when the underlying type already is Variant.
System.Object and Variant comparable operations:
- Casting: Casting from Variant to built in system type "will just work" the same way as casting from the object, e.g.
object a; uint b = (uint)a;is equivalent toVariant a; uint b = (uint)a;. Both throwInvalidCastExceptionif the cast is not possible. - Pattern matching: If you use is pattern matching use the new
TryGet/TryGetStructurecalls. If you cast using as, use the same or if you prefer a default value in case the Variant has a different type, theGet<BuiltInType>orGetStructure<T>or equivalent array returning methods ending inArray. They do not throw, but return the default value. - Reflection: Use
TypeInfoproperty on Variant to obtain metadata for for example switching. - Conversion: Previously TypeInfo had support to Cast an object aligned with Variant behavior. These API have been removed in favor of the
ConvertTo[<]BuiltInType]()members orConvertTo(BuiltInType target). NOTE: Under the hoodIConvertibleis used, which means integer values are boxed.
To migrate, perform the following general replacements in your code:
- If you are setting the
Valueproperty of Variant, change the code to create anew Variantwith the value via constructor orVariant.Fromor by casting toVariant. - Generally replace all
IList<object>withIList<Variant> - Generally replace all
ref objectwithref Variant. - In addition: for all callbacks registered in
BaseVariableStatechange the callback signature to useVariantinstead ofobjectandVariant[]instead ofobject[]. - For all remaining
object[]instances, replace withArrayOf<Variant>orIList<Variant>judiciously and depending on context. - Keep all casts from Variant (not from its Value property) to the concrete type if you intend to preserve throw behavior. For any pattern matching (is/as) use
TryGetif you need to check the result, orGet<BuiltInType>if you do not want to throw but are happy with the default value.
IMPORTANT: Care must be taken to not accidentally box a
Variantvalue into anobject. E.g. current code likeobject f = state.Valuewill not be flagged by the compiler but must be replaced withVariant f = state.Valueto remain type safe. Here it is best to usevarfor locals which requires no code changes.
Remaining work:
- Assignments to Variants and casting from variant to type should be dealt with via implicit conversion except for Structures. Here change code from
Value = <structure>toValue = Variant.FromStructure(<structure>)and<structure> = ValuetoValue.TryGetStructure(out <structure>). - Any pattern matching conversion used must be replaced with the TryGet/TryGetStructure pattern of Variant for checked conversions, e.g.
a = Value as uint?must be replaced withValue.TryGet(out uint a)which most often produces more concise code and avoids the check for nullable result of the conversion. The same applies toismatching. - For Variable and VariableType node state classes that provide a narrowed "Value" via generic
<T>any access toT Valueincurs a heavy type check. It is recommended to useWrappedValueinstead when possible for assignment and access. - While most assignments work implicitly, use
TypeInfo.GetDefaultVariantValueinstead ofTypeInfo.GetDefaultValueto initialize a variant value to a default that is!= Variant.Null.
Previously the XmlElement built in type was represented by the System.Xml.XmlElement system type. While officially a deprecated, there is now a value type XmlElement that merely wraps a string but provides conversion operations to System.Xml.XmlElement and System.Linq.Xml.XNode as well as validation and equality/hashing operations. Normally you just need to remove using System.Xml and code continues working as is. If you need to have access to the System.Xml.XmlElement cast or use the ToXmlElement method.
XmlElementtypes are compared via a normalized version of the XMLstringcontained, which removes all whitespace before comparing. This can result in some ambiguity, but operates well enough for test operations. For complete equality, cast to XNode and useDeepEquals.
EnumValue bundles a symbol with a integer value (same as StatusCode). While most API works with standard .net enum types, these do not work in scenarios where the enum value is the result of a EnumDefinition. For these
cases the EnumValue overloads provide a similar experience to using enum. In addition, the EnumValue type
allows more efficient storage inside Variant. For this case, Variant(Enum) constructor, IEquatable<Enum>, and operator ==/!=(Variant, Enum) do not exist anymore.
Change code as follows:
// Before
Variant v = new Variant(MyEnum.Value);
// After
Variant v = EnumValue.From(MyEnum.Value); // or
Variant v = new Variant(EnumValue.From(MyEnum.Value)); // or
Variant v = Variant.From(MyEnum.Value);All generated data types implementing IEncodeable are now equality comparable using == and != and implement IEquatable<T>. Equality defaults to the IsEqual implementation of the IEncodeable interface. In addition ToString() and GetHashCode() are implemented making all generated data types effectively equivalent to record classes with the exception of supporting with expressions.
Change code as follows:
No changes are required, however there can be subtle bugs exposed, e.g.:
- When comparing data type instances for reference equality, use
ReferenceEquals, instead of==or!=operators. You can use theRefEqualityComparer<T>helper when creating Dictionaries that use the type as key and require reference equality semantics for it. - When testing for
null, useis nullfor more performant code.
NodeId(string text)->NodeId.Parse(string)NodeId(object identifier, ushort namespaceIndex)-> typed constructors:new NodeId(uint, ushort),new NodeId(Guid, ushort),new NodeId(string, ushort),new NodeId(ByteString, ushort)NodeId.Create(object identifier, string namespaceUri, NamespaceTable namespaceTable)-> typed overloads:NodeId.Create(string|uint|Guid|ByteString, string, NamespaceTable)NodeId.Identifier->TryGetIdentifier(out uint|string|Guid|ByteString)orIdentifierAsStringNodeId.SetNamespaceIndex(ushort)->WithNamespaceIndex(ushort)(store the return value)NodeId.SetIdentifier(IdType, object)->WithIdentifier(uint|string|Guid|ByteString)or typed constructorsExpandedNodeId(string text)->ExpandedNodeId.Parse(string)ExpandedNodeId(object identifier, ushort namespaceIndex, string namespaceUri, uint serverIndex)-> typed constructors:new ExpandedNodeId(uint|Guid|string|ByteString, ushort, string, uint)ExpandedNodeId.Identifier->TryGetIdentifier(out uint|string|Guid|ByteString)orIdentifierAsStringNodeIdExtensions.IsNull(NodeId)->NodeId.IsNullNodeIdExtensions.IsNull(ExpandedNodeId)->ExpandedNodeId.IsNullQualifiedNameExtensions.IsNull(QualifiedName)->QualifiedName.IsNullLocalizedTextExtensions.IsNullOrEmpty(LocalizedText)->LocalizedText.IsNullOrEmptyQualifiedName.IsNull(QualifiedName)-> useQualifiedName.IsNullExtensionObject.IsNull(ExtensionObject)-> useExtensionObject.IsNull- Implicit cast from
stringorbyte[]toNodeId/ExpandedNodeId-> use explicit cast orFrom()API - Implicit cast from
stringtoLocalizedText/QualifiedName-> use explicit cast orFrom()API FormatandToStringAPIs returnstring.Emptyinstead ofnullforNodeId,QualifiedName,ExpandedNodeId,LocalizedTextto prevent NullReferenceExceptionsMatrixclass -> useMatrixOf<T><T>Collectionclasses -> useArrayOf<T>orList<T>new Variant(object)-> useVariant.From(T)Variant.Value-> useVariant.TryGet, cast, orAsBoxedObjectif absolutely necessary.DataValue.GetValue,DataValue.GetValueOrDefault, ,DataValue.Value-> useDataValue.WrappedValueand the new API on Variant (e.g.Get[Type],TryGet)
- All
<Type>Collectionclasses, e.g. Int32Collection or ArgumentCollection -> useList<Type>orArrayOf<T> ICloneable/Clone()/MemberwiseClone()on the immutable built-in types -> use assignment for copies- Creating
NodeIdorExpandedNodeIdusingbyte[]-> useByteStringand type safe constructor. - Setters removed from immutable types:
QualifiedName.Name/QualifiedName.NamespaceIndex->WithName(string)/WithNamespaceIndex(ushort)LocalizedText.Translations/LocalizedText.TranslationInfo->WithTranslations(...)/WithTranslationInfo(...)ExtensionObject.Body/ExtensionObject.TypeId-> constructors andWithTypeId(...)NodeId.NamespaceIndex/NodeId.IdType/NodeId.Identifiersetters -> use constructors orWithIdentifier(...)
- Implicit cast operator of type string to NodeId/ExpandedNodeId -> use Parse/TryParse
WriteGuid(string, Guid)-> useWriteGuid(string, Uuid)and -WriteGuidArray(string, IList<Guid>)-> useWriteGuidArray(string, ArrayOf<Uuid>)WriteDateTime(string, DateTime)-> useWriteDateTime(string, DateTimeUtc)and -WriteDateTimeArray(string, IList<DateTime>)-> useWriteDateTimeArray(string, ArrayOf<DateTimeUtc>)WriteByteString(string, byte[])-> useWriteByteString(string, ByteString)and -WriteByteStringArray(string, IList<byte[]>)-> useWriteByteStringArray(string, ArrayOf<ByteString>)- new
Variant(Guid)-> useVariant.From(Uuid)ornew Variant(Uuid) - new
Variant(DateTime)-> useVariant.From(DateTimeUtc)ornew Variant(DateTimeUtc) - new
Variant(byte[])-> useVariant.From(ByteString)ornew Variant(ByteString)orVariant.From(ArrayOf<byte>)ornew Variant(ArrayOf<byte>) - Session
Call/CallAsync(param object[])-> useCall/CallAsync(param Variant[]) byte[]as ByteString -> useByteString
The IEncoder and IDecoder interfaces have changed to use ArrayOf<T> instead of Collection and System.Array. Also generic versions of ReadEncodeable/WriteEncodeable and ReadEnumerated/WriteEnumerated were added with the ones taking a System.Type paramter removed. There are 2 versions of ReadEncodeable<T> and WriteEncodeable<T>, one with a new() constraint bypassing EncodeableFactory lookups, and one with a ExpandedNodeId used to look up the concrete type and allowing to use IEncodeable as T constraint.
Furthermore, ReadArray/WriteArray methods have been removed. A new ReadVariantValue and WriteVariantValue method has been added to write "only" the content (Value) of a Variant, or read the value using TypeInfo information. Neither supports DiagnosticInfo but also supports writing and reading scalar values. The return type is Variant. To read a TypeInfo.Scalars.Variant use ReadVariant instead because a Variant cannot contain a scalar Variant.
In addition to the generic Write/ReadEnumerated, the non-generic EnumValue variants were also added.
IEncoder:WriteEnumerated(string, EnumValue),WriteEnumeratedArray(string, ArrayOf<EnumValue>)IDecoder:ReadEnumerated(string)returningEnumValue,ReadEnumeratedArray(string)returningArrayOf<EnumValue>
Custom encoder/decoder implementations must adjust to comply with the new interfaces.
Change code as follows:
- Change all
ReadEncodeable/WriteEncodeablecalls to use the type as part of the generic expression. E.g.ReadEncodeable("field", typeof(T))toReadEncodeable<T>("field")andWriteEncodeable("field", value, typeof(T))toWriteEncodeable("field", value). If value is a type that cannot be created using a parameterless constructor, pass the type id as last argument. - Change all
ReadEnumeratedcalls to use the enumeration type as part of the generic expression. E.g.ReadEnumerated("field", typeof(T))toReadEnumerated<T>("field"). - Change calls to
ReadArray/WriteArrayto useReadVariantValueandWriteVariantValueand extract the value from the returnedVariantbased on the type you intended to read. A good example can be found inBaseComplexTypeEncodePropertyandDecodeProperty.
With the changes to Variant, the generic node state classes reflecting the inner value of the variant "value" have been changed to not rely on "casting" from object to T. The conversion is "baked in" when creating an instance of a typed state using a "builder" struct. Whether the value is scalar, array or matrix is irrelevant to which builder to use. There are 3 situations and the respective builder struct to use:
- T is a built in type -> use
VariantBuilder - T is a instance of
IEncodeable(a complex structure) -> UseStructureBuilder<T>where T is the name of the structure. - T is an instance of Enum (an enumeration) -> Use
EnumBuilder<T>where T is the name fo the enumeration type.
E.g. to create an instance of a PropertyState<T> where T is ArrayOf<ExtensionObject> use
var state = new PropertyState<ArrayOf<ExtensionObject>>.Implementation<VariantBuilder>(parent)
// or
var state = PropertyState<ArrayOf<ExtensionObject>>.With<VariantBuilder>(parent)To create an instance of a PropertyState<T> where T is Argument (an IEncodeable type) use
var state = new PropertyState<Argument>.Implementation<StructureBuilder<Argument>>(parent)
// or
var state = PropertyState<Argument>.With<StructureBuilder<Argument>>(parent)To create an instance of a PropertyState<T> where T is MatrixOf<ComplexType> (an IEncodeable type) use
var state = new PropertyState<MatrixOf<ComplexType>>.Implementation<StructureBuilder<ComplexType>>(parent)
// or
var state = PropertyState<MatrixOf<ComplexType>>.With<StructureBuilder<ComplexType>>(parent)Note: While this looks clunky, it does not use reflection and comes with 0 allocation including any allocations for Func or Action delegates and works around .net limitations regarding overload resolution for generic arguments (which also required the use of FromStructure or FromEnumeration on the Variant type instead of using From). In future versions it is possible the source generator could generate away some of the redundancies in the above expressions.
Filling the predefined node state list is now generated as source code. This means the predefined Variable and Object instance states are the generated classes, not the root node states. This has an impact on the AddBehaviorToPredefinedNode implementations which should use the received node state as "activeNode" and attach functionality to it instead of creating a active node.
Example guidance (mirrors BoilerNodeManager): the node passed to AddBehaviorToPredefinedNode is already the generated instance state, so attach behavior directly to it instead of creating a new state. This ensures the predefined list stays consistent and the generated type-specific fields are available.
protected override void AddBehaviorToPredefinedNode(
ISystemContext context,
NodeState node)
{
if (node is BoilerTypeState boiler)
{
var activeNode = boiler;
activeNode.Temperature.OnSimpleWriteValue = OnTemperatureWrite;
activeNode.FlowRate.OnSimpleWriteValue = OnFlowRateWrite;
}
// Add callbacks to the node here if necessary
// If not needed you do not need to implement this call at all.
}See NodeStates document for more information.
Breaking Change: Identity tokens no longer perform cryptographic operations directly. New handler pattern introduced for better security and lifetime management.
Before:
var token = new X509IdentityToken();
token.Encrypt(certificate, nonce, securityPolicy, context);
token.Decrypt(certificate, nonce, securityPolicy, context);
var signature = token.Sign(data, securityPolicy);
bool isValid = token.Verify(data, signature, securityPolicy);After:
var token = new X509IdentityToken();
using var handler = token.AsTokenHandler();
handler.Encrypt(certificate, nonce, securityPolicy, context);
handler.Decrypt(certificate, nonce, securityPolicy, context);
var signature = handler.Sign(data, securityPolicy);
bool isValid = handler.Verify(data, signature, securityPolicy);New Interface:
public interface IUserIdentityTokenHandler :
IDisposable, ICloneable, IEquatable<IUserIdentityTokenHandler>
{
UserIdentityToken Token { get; }
string DisplayName { get; }
UserTokenType TokenType { get; }
void UpdatePolicy(UserTokenPolicy userTokenPolicy);
void Encrypt(X509Certificate2 receiverCertificate, byte[] receiverNonce,
string securityPolicyUri, IServiceMessageContext context, ...);
void Decrypt(X509Certificate2 certificate, Nonce receiverNonce,
string securityPolicyUri, IServiceMessageContext context, ...);
SignatureData Sign(byte[] dataToSign, string securityPolicyUri);
bool Verify(byte[] dataToVerify, SignatureData signatureData, string securityPolicyUri);
}Migration Required:
-
Replace direct token crypto operations:
// OLD - Direct operations on token userIdentityToken.Encrypt(...); // NEW - Use handler pattern using var handler = userIdentityToken.AsTokenHandler(); handler.Encrypt(...);
-
Proper lifetime management:
// For temporary use - dispose immediately using var handler = token.AsTokenHandler(); handler.Encrypt(...); // For storage - clone and dispose original var storedHandler = token.AsTokenHandler().Copy(); // Use storedHandler later, remember to dispose when done
-
Available token handlers:
AnonymousIdentityTokenHandlerUserNameIdentityTokenHandlerX509IdentityTokenHandlerIssuedIdentityTokenHandler
Because Data Contract serialization is not AOT compliant and does not support trimming, all use of DataContract in the configuration has been removed. Instead, the source generator enables generating IEncodeable implementations using the DataType and DataTypeField attributes which are now consequently used for all configuration. Because the configuration is now IEncodeable the existing encoders and decoders (in particular the new XmlParser which parses Xml and allows out of order fields) compliant with Part 6 can be used to serialize and deserialize all configuration and configuration extensions.
Generated Data types still support DataContract based serialization, however, consider this a deprecated feature.
All configuration DTO classes (ApplicationConfiguration, ServerConfiguration, TraceConfiguration, TransportConfiguration, ServerSecurityPolicy, OAuth2ServerSettings, OAuth2Credential, GlobalDiscoveryServerConfiguration, CertificateGroupConfiguration, BrowserOptions, etc.) migrated from [DataContract]/[DataMember] to source-generated [DataType]/[DataTypeField] attributes and are now partial classes.
Change code as follows:
- Replace
[DataContract(Namespace = ...)]with[DataType(Namespace = ...)]and[DataMember(...)]with[DataTypeField(...)]on custom configuration subtypes. - Add the
partialkeyword to any subclass of these configuration types. - Custom configuration extension types must implement
IEncodeable(the[DataType]source generator handles this automatically forpartialclasses). - Code using reflection to inspect
[DataContract]/[DataMember]attributes must switch to[DataType]/[DataTypeField].
All List<T>-based collection wrappers for configuration types have been removed and replaced with ArrayOf<T>: ServerSecurityPolicyCollection, TransportConfigurationCollection, SamplingRateGroupCollection, ReverseConnectClientCollection, ReverseConnectClientEndpointCollection, ServerRegistrationCollection, CertificateIdentifierCollection, CertificateGroupConfigurationCollection, OAuth2ServerSettingsCollection, OAuth2CredentialCollection.
See the ArrayOf and MatrixOf section for migration guidance on using ArrayOf<T>.
DataContractSerializer has been removed from config loading and persistence paths:
ApplicationConfiguration.LoadWithNoValidationusesXmlParser/IEncodeable.Decode(). Existing XML config files should remain loadable.- Browser and session state persistence switched from XML to OPC UA Binary encoding. Old persisted files cannot be loaded — delete and re-save.
SecuredApplicationusesSecuredApplicationEncodinghelpers instead ofDataContractSerializer.
Newtonsoft.Json is no longer a dependency of Opc.Ua.Core. Projects relying on its transitive availability must add an explicit reference:
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />ParseExtension<T>() and UpdateExtension<T>() now require T to implement IEncodeable. New delegate-based overloads were added for custom decoding:
// Generic overload (T must implement IEncodeable)
var config = configuration.ParseExtension<MyConfig>();
// Delegate overload for custom decoding
var config = configuration.ParseExtension<MyConfig>(
new XmlQualifiedName("MyConfig", myNamespace),
decoder => { var c = new MyConfig(); c.Decode(decoder); return c; });NodeState.Clone() is now a concrete method that calls CreateCopy() + CopyTo(). The new protected abstract NodeState CreateCopy() must be overridden by all direct NodeState subclasses.
// Before
public override object Clone()
{
var clone = new MyNodeState(Parent);
CopyTo(clone);
return clone;
}
// After
protected override NodeState CreateCopy()
{
return new MyNodeState(Parent);
}If you had custom deep-copy logic beyond what CopyTo() does, override CopyTo() instead.
The protected ServiceResult Read(object, ref object) and protected object Write(object) methods were removed.
Use the CopyPolicy property or the new CopyOnWrite bool directly with CoreUtils.Clone() for copy-on-read/write semantics.
OnAfterCreate(ISystemContext, NodeState) now has an optional CancellationToken ct = default parameter.
Existing overrides compile (source-compatible) but are binary-incompatible — pre-compiled assemblies won't match at runtime.
protected override void OnAfterCreate(ISystemContext context, NodeState node, CancellationToken ct = default)
{
base.OnAfterCreate(context, node, ct);
}New type abstraction layer: IType (base) with IBuiltInType, IEnumeratedType (new), and IEncodeableType (now extends IType). Many APIs return IType instead of Type:
TypeInfo.GetSystemType(ExpandedNodeId, IEncodeableTypeLookup)→ returnsIType(wasType). Use.Typeproperty to get the CLRType.- The overload
TypeInfo.GetSystemType(BuiltInType, int valueRank)was removed.
TryGetEncodeableType<T>()removed.- Added:
TryGetEnumeratedType(ExpandedNodeId, out IEnumeratedType?),TryGetType(XmlQualifiedName, out IType?).
AddEncodeableType(ExpandedNodeId, Type)→ renamed toAddType(ExpandedNodeId, Type).- Added:
AddEnumeratedType(IEnumeratedType),AddEnumeratedType(ExpandedNodeId, IEnumeratedType). AddEncodeableType(Type)andAddEncodeableTypes(Assembly)now have AOT annotations ([DynamicallyAccessedMembers],[RequiresUnreferencedCode]).
The [Obsolete] static EncodeableFactory.GlobalFactory was removed. EncodeableFactory.Create() renamed to Fork(). Use ServiceMessageContext.Factory instead.
ExtensionObject.ToArray(object, Type) and ToList<T>(object) removed. Use extensionObjects.GetStructuresOf<T>() or ExtensionObject.ToArray<T>(ArrayOf<ExtensionObject>).
Core complex type interfaces and default (non-reflection-emit) implementations moved from Opc.Ua.Client.ComplexTypes to Libraries/Opc.Ua.Client/ComplexTypes/.
Namespace remains Opc.Ua.Client.ComplexTypes. If you used the default constructors without specifying the builder, and want to use the Reflection.Emit based type builders,
you need to change your code to call ComplexTypeSystem.Create(...) instead of new ComplexTypeSystem(...) which now uses the new default builder not supporting Reflection.Emit.
Concrete Structure-backed sub-types of the abstract OptionSet DataType (i=12755) are now automatically registered by the default ComplexTypeSystem builder with a new runtime class Opc.Ua.Encoders.OptionSet (in Stack/Opc.Ua.Types). Bit-field metadata is resolved from DataTypeDefinition (EnumDefinition) or, as a fallback, synthesized from the OptionSetValues property (LocalizedText[]).
Impact on existing code:
- Source-breaking for custom
IComplexTypeBuilderimplementations: a new memberAddOptionSetType(QualifiedName, ExpandedNodeId, ExpandedNodeId, ExpandedNodeId, ExpandedNodeId, EnumDefinition)was added toIComplexTypeBuilder. Custom implementations must provide it. - The Reflection.Emit builder in
Opc.Ua.Client.ComplexTypesthrowsNotSupportedExceptionfromAddOptionSetType; callers relying on the Reflection.Emit path for OptionSet sub-types should switch to the default builder (new ComplexTypeSystem(session)). - No wire-format changes: encoders/decoders continue to route through
IEncodeableFactory→IEncodeableType.CreateInstance, which now yieldsOpc.Ua.Encoders.OptionSetfor registered sub-types. - UInteger-backed OptionSet DataTypes remain treated as their underlying unsigned integer in a
Variant(unchanged).
Breaking Change: Persistence switched from DataContractSerializer XML to IEncoder and IDecoder. BrowserState, SessionState, SessionOptions, SubscriptionState, and MonitoredItemState are annotated with [DataType] and use the standard Encode/Decode methods generated by the source generator.
To register the state types with the encodeable factory:
context.Factory.Builder.AddOpcUaClientDataTypes();The following property types have changed to use the new stack value types:
| Class | Property | Old Type | New Type |
|---|---|---|---|
SessionState |
ServerNonce |
byte[]? |
ByteString |
SessionState |
ClientNonce |
byte[]? |
ByteString |
SessionState |
ServerEccEphemeralKey |
byte[]? |
ByteString |
SessionState |
Timestamp |
DateTime |
DateTimeUtc |
SessionState |
Subscriptions |
SubscriptionStateCollection? |
ArrayOf<SubscriptionState> |
SubscriptionState |
MonitoredItems |
MonitoredItemStateCollection |
ArrayOf<MonitoredItemState> |
SubscriptionState |
Timestamp |
DateTime |
DateTimeUtc |
SessionOptions.Identity (IUserIdentity?) is no longer a serialized field. It is a computed property backed by UserIdentityToken? IdentityToken, which is the actual serialized field:
public partial record class SessionOptions
{
// Serialized field
[DataTypeField(Order = 2, StructureHandling = StructureHandling.ExtensionObject)]
public UserIdentityToken? IdentityToken { get; set; }
// Computed — not serialized
public IUserIdentity? Identity
{
get => IdentityToken != null ? new UserIdentity(IdentityToken) : null;
set => IdentityToken = value?.TokenHandler?.Token;
}
}The encoding format for session state has changed. Existing persisted session state files cannot be loaded by the new SessionConfiguration.Create() method. Handle restore failures and re-persist the new session state.
Breaking Change: Boolean properties on source-generated data types now correctly default to false instead of true.
Generated code produced by the model compiler contained a bug because it inverted the default value for boolean fields in generated data types. Boolean fields without an explicit <DefaultValue> in the model design XML were initialized to true instead of false as expected and defined in Part 6. This has been fixed.
Impact: Any code that creates instances of source-generated data types and relies on boolean properties being true by default must now explicitly set those properties to true. This primarily affects PubSub configuration types:
| Type | Property | Old Default | New Default |
|---|---|---|---|
PubSubConfigurationDataType |
Enabled |
true |
false |
PubSubConnectionDataType |
Enabled |
true |
false |
WriterGroupDataType |
Enabled |
true |
false |
ReaderGroupDataType |
Enabled |
true |
false |
DataSetWriterDataType |
Enabled |
true |
false |
DataSetReaderDataType |
Enabled |
true |
false |
PublishedDataSetCustomSourceDataType |
CyclicDataSet |
true |
false |
Other affected types include all source-generated structures with boolean fields (e.g., AggregateConfiguration.TreatUncertainAsBad, MonitoringParameters.DiscardOldest, CreateSubscriptionRequest.PublishingEnabled) as well as
some hand-written types in Opc.Ua.Types (such as BrowseDescription, RelativePathElement).
Migration: Add explicit initialization where your code depends on true as the default:
// Before (relied on incorrect true default)
var connection = new PubSubConnectionDataType
{
Name = "MyConnection"
};
// After (explicitly set Enabled)
var connection = new PubSubConnectionDataType
{
Enabled = true,
Name = "MyConnection"
};The Opc.Ua.Gds.Client.Common package has undergone a significant cleanup. Two breaking changes affect almost every consumer of the GDS / LDS / Server-Push client APIs.
Breaking Change: All asynchronous methods on IGlobalDiscoveryServerClient, ILocalDiscoveryServerClient, and IServerPushConfigurationClient (and their concrete implementations) now return ValueTask / ValueTask<T> instead of Task / Task<T>.
Rationale: Many GDS operations complete synchronously when a session is already established. Returning ValueTask avoids the per-call Task allocation on those fast paths and keeps the surface consistent with the rest of the modernized client stack.
Impact: Pure await callers require no change — await works identically on Task and ValueTask. However, two patterns require a small adjustment.
| Pattern | Old (Task) |
New (ValueTask) |
|---|---|---|
await on the return value |
works | works (no change) |
Block synchronously via .Result / .Wait() |
works | use .AsTask().Result / .AsTask().Wait() |
Combine results with Task.WhenAll / Task.WhenAny |
works | call .AsTask() first |
| Await the same return value more than once | works | not supported — call .AsTask() first |
Important: A
ValueTaskmay be awaited only once and the underlying value source must not be observed after the operation has completed. If you need to await a result more than once, fan it out across multiple consumers, or pass it to anything other than a singleawait, materialize it via.AsTask()first.
// Before
Task<NodeId> registration = gds.RegisterApplicationAsync(application, ct);
NodeId id = await registration;
await Task.WhenAll(registration, otherTask); // worked
// After
ValueTask<NodeId> registration = gds.RegisterApplicationAsync(application, ct);
NodeId id = await registration; // unchanged
// Multi-await / Task.WhenAll: materialize first
Task<NodeId> asTask = gds.RegisterApplicationAsync(application, ct).AsTask();
await Task.WhenAll(asTask, otherTask);Breaking Change: All [Obsolete] synchronous wrappers, APM (Begin*/End*) methods, and other deprecated members have been removed from the GDS client surface.
Affected APIs (non-exhaustive):
- All synchronous wrappers on
GlobalDiscoveryServerClient(~25 methods such asFindApplication,RegisterApplication,StartNewKeyPairRequest, …) — use the corresponding*Asyncoverload returningValueTask/ValueTask<T>. - All synchronous wrappers on
ServerPushConfigurationClient(~14 methods such asUpdateCertificate,ReadTrustList,ApplyChanges, …) — use the*Asyncoverload. - APM (
Begin*/End*) overloads onLocalDiscoveryServerClient(e.g.BeginFindServers/EndFindServers) — use the*Asyncoverload. - The capability identifier constants are now source-generated as
Opc.Ua.ServerCapability(singular, e.g.ServerCapability.GDS,ServerCapability.LDS,ServerCapability.DA). The[Obsolete] public const stringshims previously exposed on the value-typeServerCapabilityclass (nowServerCapabilityInfoinOpc.Ua.Gds.Client) have been removed. The runtimeServerCapabilities.csvparsing path (which never actually loaded — the resource was not embedded) has been replaced by the generated dictionaryServerCapability.All. The instance enumerable previously namedServerCapabilityCatalogis nowOpc.Ua.Gds.Client.ServerCapabilitiesand itsFindreturnsServerCapabilityInfo. RegisteredApplicationis now asealed record; the obsolete extension methods that wrapped its property access have been removed — use the record properties directly.CertificateWrapperis nowsealedand no longer implementsIEncodeable; remove any code that treated it as an encodeable.
Migration:
// Before
var apps = gds.FindApplication(uri); // sync wrapper
var caps = ServerCapability.GlobalDiscoveryServer; // obsolete shim
// After
var apps = await gds.FindApplicationAsync(uri, ct);
var caps = ServerCapability.GDS;If you currently rely on a [Obsolete] member, switch to the Async equivalent and apply the ValueTask migration notes above. If a particular API has no direct replacement, the migration is described inline in the XML doc comment of the replacement member.
The server now supports AsyncNodeManagers, see Server Async (TAP) Support. The client APIs are async by default and all synchronous and APM
based API has been deprecated. To migrate update your code to use the Async version of all API if possible. Not recommended but for expedience sake you can use the Async
version and make it sync by appending GetAwaiter().GetResult() to it.
Observability via ITelemetryContext in preparation for better DI support. See documentation for breaking changes.
- A few features are still missing to fully comply for 1.05, but certification for V1.04 is still possible with the 1.05 release.
For additional migration support:
- Review sample applications in the repository
- Check unit tests for usage patterns
- Consult the OPC Foundation community forums
- Report issues in the GitHub repository