Today, let’s talk about how we used Rust’s trait system to provide a clean, safe, and intuitive developer experience (DevX) while porting Ethereum’s ERC standards to Stellar Soroban. It includes some trait juggling, which I enjoyed quite a bit whilst doing it. So I decided to share it with you.
Rust doesn’t have inheritance. And that sometimes creates some inconveniences…
Problem #1: Conflicting Extensions in ERC721
When implementing ERC721 (NFTs) on Soroban, we encountered a delightful (read: infuriating) issue: some extensions conflict with each other. For example, Enumerable is fundamentally incompatible with Consecutive.
Problem #2: Overriding Methods
On top of that, these extensions need to override methods like mint, burn, and transfer, but Rust doesn’t have a built-in override mechanism like traditional OOP languages.

Expectations From a Good Solution
In Rust, there are many ways to work around inheritance. But all of them have their own trade-offs. Let’s define what we don’t want to compromise on…
What We Need: Type Safety
Why do we need type safety? To prevent developers from implementing incompatible extensions, and let them know they’ve made questionable life choices at compile time.
What We Also Need: An Intuitive API
We should let developers define their NFT contracts naturally. If they have to import obscure intermediate traits, and decorate everything with generics, sure, we achieved type safety, but at what cost?
Let’s try to tackle our problems one by one…
Enforcing Mutually Exclusive Traits
Our first goal for the end-developer experience looked something like this:
impl NonFungibleToken for MyContract {
/* NFT methods */
}
impl NonFungibleEnumerable for MyContract {
/* Enumerable methods */
}
// SHOULD NOT COMPILE: Enumerable and Consecutive are incompatible
impl NonFungibleConsecutive for MyContract {
/* Consecutive methods */
}
Unfortunately, Rust doesn’t support negative trait bounds, so we can’t write:
trait NonFungibleEnumerable: !NonFungibleConsecutive {}
Since we can’t use negative bounds, we flipped the problem: let’s use positive bounds instead. The idea is to enforce contract types explicitly.
trait NonFungibleToken {
type ContractType;
/* methods here */
}
We will have 3 Contract Types:
Base: the base trait where we expect no override on the default behavior forNonFungibleTokenmethods.Enumerable: the extension that overrides some of theNonFungibleTokenmethods.Consecutive: another extension that overrides some other methods ofNonFungibleToken.
Then, define extensions like this:
trait NonFungibleEnumerable: NonFungibleToken<ContractType = Enumerable> {}
trait NonFungibleConsecutive: NonFungibleToken<ContractType = Consecutive> {}
Now, when implementing NonFungibleToken, the developer must specify the contract type:
impl NonFungibleToken for MyContract {
type ContractType = Enumerable;
}
Since Consecutive expects a different contract type, it cannot be implemented at the same time.
And voila! We achieved mutually exclusive traits:
impl NonFungibleToken for MyContract {
type ContractType = Enumerable;
/* NFT methods */
}
impl NonFungibleEnumerable for MyContract {
/* Enumerable methods */
}
// SHOULD NOT COMPILE: Enumerable and Consecutive are incompatible
impl NonFungibleConsecutive for MyContract {
/* Consecutive methods */
}

So, we solved the first problem: mutually exclusive traits. And we just needed to put an associated type for that. Very straightforward, and does not hinder the developer experience a lot. Definitely worth the trade-off in my opinion!
Overriding Default Behavior for Different Contract Types
Avoiding Generics for Function Overrides
Another challenge: some functions (like transfer) behave differently depending on whether Enumerable or Consecutive is implemented. One way to handle this is by adding generics:
fn transfer<T: ContractType>() {}
But generics here feel… off. The end-developer shouldn’t have to worry about passing transfer::<Enumerable>()—it’s unintuitive.
Let’s break this down:
Generics Discussion
Note: The babbling below, is completely subjective, and you shouldn’t feel bad if you do not agree with me.
Generics make sense when they represent what you’re operating on. For example, insert::<String>() or insert::<u32>() makes perfect sense—you’re inserting different types of values, and the generic parameter describes the thing being inserted. The type varies per call, and the developer naturally thinks about it.
But transfer::<Enumerable>()? That’s different. The generic here doesn’t describe what you’re transferring. It describes how the contract itself is configured. The contract type is a property of the contract, not of the function call. It’s decided once when you implement the trait, not every time you call transfer().
From the developer’s perspective, transfer() just moves a token from one account to another. The signature is the same regardless of whether the contract is Base, Enumerable, or Consecutive. Making them write transfer::<Enumerable>() forces them to think about an implementation detail that shouldn’t leak into their mental model.
In short: generics shine when they parameterize inputs that vary per call. But when they’re used to encode compile-time configuration of the contract itself, they feel like the wrong abstraction.
So, we needed a way to override functions without generics.
Note: Babbling ends here. If you do not agree with me for the rest of the article, you should feel bad.
Trait-Based Solution
Behold! Trait-Based Function Dispatch!
We tweaked our NonFungibleToken trait:
trait NonFungibleToken {
type ContractType: ContractOverrides;
fn transfer(/* args */) {
Self::ContractType::transfer() // here is the default implementation,
// which relies on `ContractType`, and changing behavior based on the contract type
}
}
Here, ContractOverrides trait provides all the methods that NonFungibleToken has. If you are confused why do we have the same trait duplicated, it is actually quite simple. Let me explain it step by step:
- we need to override some methods of
NonFungibleTokentrait. - but this override should be done by the
ContractType, right? IfContractTypeisEnumerable, then the methods of theNonFungibleTokenshould be overridden accordingly to theEnumerable. - so, we need to have access to the overridden function from the type
Enumerable. - to expose these functions over the
ContractTypevariants, we define a traitContractOverrides, and bound theContractTypeby it. - then, we can implement the
ContractOverridestrait for theContractTypevariants. - and in our contract, we can use the
ContractOverridestrait to override the methods of theNonFungibleTokentrait.
That was a lot… Let’s take a breather.
Here’s a niche detail: ContractOverrides provides the actual default implementations.
pub trait ContractOverrides {
fn owner_of(e: &Env, from: Address, to: Address, token_id: TokenId) {
Base::owner_of(e, &from, &to, token_id);
}
}
So that, for the functions we do not want to override, the default ones will be used. For example, in Enumerable, we are not interested in owner_of function. So, we can just leave it empty, and the default implementation will be used.
Ok, but where does the actual implementation of owner_of come from? More concretely, what is Base::owner_of?
Here is it:
pub struct Base;
impl Base {
fn owner_of(e: &Env, from: Address, to: Address, token_id: TokenId) {
// actual implementation of it
}
}
And what if we don’t want to override anything for our contract, and go with the Base variant? How will ContractOverrides implementation will look like for Base?
impl ContractOverrides for Base {
// this is EMPTY, because we do not need to override anything!
};
But Enumerable overrides some behavior:
pub struct Enumerable;
impl Enumerable {
fn transfer(e: &Env, from: Address, to: Address, token_id: TokenId) {
// actual `transfer()` implementation for the `Enumerable` variant...
// do enumerable stuff here for overriding the `transfer()`.
}
}
And the ContractOverrides implementation for Enumerable will be:
impl ContractOverrides for Enumerable {
fn transfer(e: &Env, from: Address, to: Address, token_id: TokenId) {
Enumerable::transfer(e, &from, &to, token_id);
}
}
Now, when a contract specifies type ContractType = Enumerable;, all the methods automatically get the right behavior. No need for the developer to manually override anything! And, of course, our mutually exclusive traits remain intact.
The Architecture
Here is another way to look at the ContractOverrides trait (which is essentially the duplicate of NonFungibleToken trait).
Recall our goal:
impl NonFungibleToken for MyContract {
/* NFT methods */
}
impl NonFungibleEnumerable for MyContract {
/* Enumerable methods */
}
// SHOULD NOT COMPILE: Enumerable and Consecutive are incompatible
impl NonFungibleConsecutive for MyContract {
/* Consecutive methods */
}
For our smart contracts, we want to have each trait separately defined on our Smart Contract for the modularity (take a look at the code above once more).
And we need to alter the behavior of NonFungbileToken trait implementation, if there is also NonFungibleEnumerable trait implemented. This is WEIRD for Rust, but makes perfect sense in our scenario.
How do you make a trait alter it’s behavior based on the existence of another trait?
You simply can’t!
But you can do another thing: you can have a helper type, on which your trait (NonFungibleToken in this case) will change its behavior. And the other trait (NonFungibleEnumerable) you will implement, can only be implemented for a specific helper type.
Now, our main trait NonFungibleToken, cannot have direct default implementations, because we want them to be dynamic (based on the existence of other traits, in other words, based on the ContractType).
So we need a second layer here, hence the ContractOverrides trait…
On a more structural/hierarchical level, it looks like this:
These are on the same level, and provide the actual default implementations:
ContractOverridesforBaseContractOverridesforEnumerableContractOverridesforConsecutive
And we have a super trait, NonFungibleToken, takes one of these concrete types, and adjusts its behavior based on that very type, utilizing the ContractOverrides trait.
In other words, NonFungibleToken is just the empty wrapper trait, which uses ContractOverrides trait and the associated type to adjust its behavior.
Result
You may be thinking: “all the trickery you’ve explained above, wouldn’t generics be easier instead of all that?”
You are right, generics could have been easier!
And who knows, maybe in the future I’ll change my mind about this specific case. As I mentioned before, this is a subjective take. But I think even those who prefer generics over this trait juggling, would appreciate what we’ve accomplished with traits here.
The end-developer does not need to know any of the stuff I’ve explained in this post. The traits and associated types for sure made the library more complex for the maintainers, but observe how all this complexity is hidden from the end-developer:
impl NonFungibleToken for MyContract {
type ContractType = Enumerable;
/* NO METHODS NEEDED! */
/* and necessary overrides already took place thanks to `Enumerable` type */
}
impl NonFungibleEnumerable for MyContract {
/* NO METHODS NEEDED! */
/* and if you like to override a method for your custom needs, you can */
fn transfer(e: &Env, from: Address, to: Address, token_id: TokenId) {
// your stuff
}
}
// BELOW SHOULD NOT COMPILE: Enumerable and Consecutive are incompatible
impl NonFungibleConsecutive for MyContract {}
and I think that is beautiful.