Files
Structive/docs/DESIGN.md
T
2026-08-07 17:15:02 +08:00

15 KiB
Raw Blame History

Structive Design Philosophy and Principles

中文

1. Purpose

Structive exists to give ordinary C++ object types a machine-readable structural layer without forcing those types into a framework-specific storage model.

The library should make it possible to ask questions such as:

  • Which members are part of the public structural model?
  • What is the stable runtime key of a property?
  • Which properties are externally readable or writable?
  • Which properties participate in persistence?
  • Which metadata belongs to presentation, validation or another extension?
  • Which properties share a synchronization domain?
  • How can generic runtime code read or write a property safely with respect to declared capabilities?

It should answer those questions while leaving the underlying type recognizably C++.

2. Primary principle: enhance, do not replace

Structive is not a replacement object model. It is a structural layer attached to an existing C++ type.

The preferred shape is:

struct Device : structive::Property_Object<Device> {
    double temperature;
    std::string name;
};

not:

struct Device {
    Framework_Property<double> temperature;
    Framework_Property<std::string> name;
};

This principle protects several properties of normal C++ code:

  1. Members remain real members.
  2. Member pointers remain meaningful identities.
  3. Raw access remains possible where ownership rules permit it.
  4. Unregistered members can remain implementation details.
  5. Generic Structive metadata can evolve independently from storage representation.

The cost of this principle is intentional: Structive cannot intercept raw member access. Managed behavior only applies when callers use the managed path.

3. Two layers, not one

Structive separates type-level description from instance-level management.

3.1 Type layer

Type_Descriptor<T> produces an Object_Schema describing:

  • registered properties;
  • declared property keys;
  • accessors;
  • Attributes;
  • constraints;
  • object-level inheritable defaults;
  • a default synchronization plan.

This is the structural definition of the type.

3.2 Instance layer

Property_Object<T> provides:

  • a type-shared resolved default lock-slot topology;
  • per-instance mutex storage only when the selected lock policy requires real mutexes;
  • a compact per-instance topology only for explicit synchronization overrides;
  • managed typed reads and writes;
  • capability views;
  • static and runtime multi-property guards;
  • traversal over managed values;
  • type-erased runtime access through Property_Object_Base.

This is instance behavior, not schema identity.

3.3 Design rule

Do not move mutable instance synchronization state into the schema, and do not make schema metadata depend on one particular instance-management policy. Immutable topology derived from the type-level default plan may be shared across all instances of that type.

A future user may want to describe a large number of plain objects without paying per-object synchronization cost. The architecture should continue to leave that possibility open.

4. Registration is explicit

Structive does not assume every C++ member belongs to the structural model.

struct Device : structive::Property_Object<Device> {
    int id;
    double temperature;
    mutable int internal_cache;
};

If only id and temperature are registered, internal_cache is invisible to Structive.

This is deliberate. Structural exposure is an API decision and should not be inferred from physical layout.

Rule

The schema is the public structural contract; the struct layout is not automatically the schema.

5. Prefer compile-time identity inside C++

Business C++ code should normally identify member-backed properties by member pointer:

device.read<&Device::temperature>();
device.write<&Device::temperature>(30.0);
device.schema().property<&Device::temperature>();

This gives the compiler the strongest relationship between the object type and the selected field.

Numeric indexes are useful for generic compile-time traversal. String keys are useful at runtime boundaries.

Identity hierarchy

business C++ code        → member pointer
compile-time generic code → property index
runtime/adapters          → declared string key

Do not unnecessarily convert compile-time code to strings merely because a key exists.

6. Keys are structural protocol identifiers

Every Property_Descriptor requires a non-empty key Attribute. Keys must be unique inside one schema.

A key is not merely a UI label. It is the runtime identity used by schema lookup, dynamic lock selection and runtime access.

Changing a key may therefore change an external protocol or persistence contract even when the C++ member name stays unchanged.

Rule

Treat property keys as protocol-level names. Rename them deliberately.

7. One Attribute system

Structive has one Attribute protocol. It should not grow parallel metadata channels such as “hint”, “annotation”, “UI metadata” and “serializer metadata” with separate storage machinery.

An Attribute may declare:

  • attribute_category for category-based lookup;
  • single_valued when only one Attribute of that category may appear in a declaration;
  • inheritable when it may participate in object defaults;
  • any payload required by the category owner.

Core and extensions use this same mechanism.

Rule

Add semantics by adding an Attribute category and an interpreter, not by adding a second metadata framework.

8. Category ownership

Core should interpret only categories that belong to Core.

For example, Core understands access and persistence capability because those affect Core-managed views. It does not need to understand presentation::label.

Presentation owns presentation semantics. A future JSON extension should own JSON-specific semantics. An RPC extension should own RPC-specific semantics.

The Core still stores and traverses all Attributes uniformly.

Dependency rule

extension implementation
        ↓
Structive Property Core

Never reverse this dependency merely to make an extension convenient.

9. Defaults are inheritance, not hidden mutation

defaults(...) supports inheritable Attribute categories. Property declarations remain able to override those defaults.

Current Core inheritable categories include external access, persistence access and sensitivity.

This mechanism should be used for stable object-wide policy defaults, not as a general mechanism for implicit behavior.

Rule

Defaults should reduce repetition without making a propertys effective policy impossible to determine from schema rules.

10. Managed access and raw access are distinct contracts

Raw access:

device.temperature = 30.0;

Managed access:

device.write<&Device::temperature>(30.0);

The raw path is normal C++. It does not acquire Structive locks or enforce Structive capability views.

The managed path uses the descriptor and synchronization topology.

This duality is intentional rather than accidental.

Rule

Do not pretend that inheriting Property_Object<T> turns public C++ members into encapsulated properties. If a subsystem requires managed synchronization, its coding rules must require the managed path.

11. Intrinsic capability comes before boundary projections

Every property first has capabilities defined by its accessor itself:

intrinsic
├── read
└── write

Normal managed application code uses those intrinsic capabilities directly through read(), write(), locks and traversal. internal is therefore not a separate capability mode.

External and persistence behavior are boundary projections over that same property definition:

Property
├── intrinsic: read / write
├── external: read / write projection
└── persistence: load / store projection

Core combines projection Attributes with the accessor's actual abilities. A projection may narrow intrinsic capability, but it must never invent an ability the accessor does not provide.

An external view should not become a second schema. A persistence view should not become a second schema. They are projections over one schema.

Rule

One property definition, intrinsic capabilities, explicit boundary projections.

12. Validation is metadata plus an explicit operation

Structive constraints describe candidate validity, but write() does not automatically run them.

This separation is essential because these are different concerns:

single-property constraint
cross-property invariant
locking
transaction boundary
rollback strategy
error reporting
side effects

A generic write() cannot correctly guess all of them.

Example

auto guard = device.lock_unique<&Device::min_speed, &Device::max_speed>();
auto old_min = guard.get<&Device::min_speed>();
auto old_max = guard.get<&Device::max_speed>();
guard.set<&Device::min_speed>(candidate_min);
guard.set<&Device::max_speed>(candidate_max);
if (candidate_min > candidate_max) {
    guard.set<&Device::min_speed>(old_min);
    guard.set<&Device::max_speed>(old_max);
}

The business operation owns the invariant and rollback semantics.

Rule

Do not turn write() into a hidden transaction engine.

13. Synchronization is synchronization only

A synchronization plan maps properties to lock slots. It supports independent, shared, unsynchronized and grouped configurations.

The purpose of a group is to define a consistency domain: properties in the group share a mutex slot.

Multi-property lock operations deduplicate slots and acquire them in stable slot order.

Synchronization does not mean

  • validation;
  • transaction;
  • rollback;
  • event emission;
  • dirty tracking;
  • persistence commit.

Those may be built above Structive, but should not be silently coupled to locking.

14. Computed properties must have explicit consistency boundaries

computed_property reads through a synchronized view. The view only permits access to properties in the same resolved lock slot.

Therefore a computed property that depends on min_speed and max_speed should share their synchronization group:

synchronization(
    sync_all_independent,
    sync_group("speed", "min_speed", "max_speed", "speed_span")
)

This makes consistency explicit rather than relying on a getter that casually reads unrelated fields.

Trusted accessors

trusted_computed_property and trusted_accessor_property invoke trusted member functions on the object. They bypass synchronized-view dependency checking by design.

Rule

Use synchronized computed properties by default. Use trusted accessors only when the accessor itself owns or guarantees the required synchronization semantics.

15. Runtime access is a boundary feature

Property_Object_Base intentionally provides a type-erased runtime interface using:

  • string key;
  • intrinsic access when no mode is supplied;
  • Managed_Access_Mode only when selecting the external or persistence projection;
  • std::type_info;
  • explicit result codes.

This is appropriate for adapters that do not know the concrete type at compile time.

It is not a reason to make normal typed C++ code dynamic.

Rule

Keep compile-time code compile-time; cross dynamic boundaries only where the application actually has a dynamic boundary.

16. Runtime errors and compile-time errors have different jobs

Structive uses compile-time rejection where the selection is statically known:

  • missing member registration;
  • duplicate keys;
  • duplicate member storage identity;
  • invalid Attribute category duplication;
  • non-inheritable Attributes inside defaults(...);
  • impossible access capability for an accessor.

Runtime failures are reserved for runtime inputs and runtime configuration:

  • unknown dynamic key;
  • inaccessible dynamic property;
  • invalid synchronization plan key;
  • duplicate runtime synchronization configuration;
  • runtime type mismatch.

Rule

Do not defer a statically knowable schema error to runtime. Do not force dynamic adapter input into compile-time machinery.

17. Synchronization policy should remain replaceable

Property_Object<Derived, Lock_Policy> supports a lock policy, with Shared_Mutex_Policy as the default and No_Lock_Policy available.

This is an important architectural seam. Core semantics should not become inseparable from one specific mutex type or one global scheduler.

No_Lock_Policy removes actual mutual exclusion; it does not magically make concurrent access safe.

18. Extension design rules

A good Structive extension should:

  1. Define categories that belong to its own domain.
  2. Reuse the Core Attribute protocol.
  3. Interpret only the categories it owns or explicitly depends on.
  4. Depend on Core, never require Core to depend on it.
  5. Keep domain-specific fallback behavior inside the extension.
  6. Prefer reading the existing schema over duplicating a parallel descriptor tree.
  7. Avoid changing Core behavior merely because an adapter needs a convenience rule.

19. Non-goals

Structive Core should not become all of the following at once:

  • an ORM;
  • a JSON library;
  • a GUI framework;
  • an RPC framework;
  • a signal/slot system;
  • a transaction engine;
  • a general-purpose reflection replacement for every language feature.

Structive should provide a strong structural contract that those systems can consume.

20. Evolution rules

When changing the library, apply these questions in order:

  1. Does this belong to the structural model, instance management, or an extension?
  2. Is this new concept really an Attribute category rather than a new metadata channel?
  3. Can the error be detected at compile time?
  4. Does the change preserve native C++ member semantics?
  5. Does it preserve the distinction between raw and managed access?
  6. Is synchronization being mixed with validation, transaction or events?
  7. Does Core now know something that should belong to an extension?
  8. Does the API still make member pointers the natural typed identity?
  9. Does a new convenience API duplicate an existing semantic path?
  10. Does the change preserve existing function-signature meaning rather than silently redefining it?

If a feature fails these questions, reconsider its layer before implementing it.

21. Architectural summary

Structive should remain a small number of strong concepts rather than a large number of magical conveniences:

ordinary C++ type
    + explicit schema
    + one Attribute model
    + explicit capabilities
    + explicit validation
    + explicit synchronization
    + optional managed instance behavior
    + extension-owned interpretation

The library is strongest when generic systems can understand a type without taking ownership of that type.