diff --git a/doc/hello_world.rst b/doc/hello_world.rst
index 0610c7e..0461053 100644
--- a/doc/hello_world.rst
+++ b/doc/hello_world.rst
@@ -81,7 +81,7 @@ Second, we add to the ``any_iface`` definition an ``impl`` template alias to ind
    destructors.
 
 Note that this is an *intrusive* way of specifying the implementation of an interface.
-A non-intrusive alternative is also available, so that it is possible to provide
+A :ref:`non-intrusive alternative <nonintrusive>` is also available, so that it is possible to provide
 implementations for existing interfaces without modifying them.
 
 And we are done! We can now use ``any_iface`` in the definition of a type-erased
diff --git a/doc/nonintrusive.rst b/doc/nonintrusive.rst
new file mode 100644
index 0000000..8f1c2aa
--- /dev/null
+++ b/doc/nonintrusive.rst
@@ -0,0 +1,49 @@
+.. _nonintrusive:
+
+.. cpp:namespace-push:: tanuki
+
+Non-intrusive interface implementations
+=======================================
+
+In all the examples seen so far, implementations for tanuki interfaces have always
+been specified by adding an ``impl`` template alias in the body of the interface class.
+This is an *intrusive* approach, in the sense that it requires modifying the definition
+of the interface.
+
+In order to be able to adapt existing object-oriented interfaces without having to modify them
+by adding the ``impl`` alias, tanuki also supports a non-intrusive way of specifying
+interface implementations. Let us see it in action.
+
+Consider the following object-oriented interface ``my_iface`` defined in some namespace ``ns``:
+
+.. literalinclude:: ../tutorial/nonintrusive.cpp
+   :language: c++
+   :lines: 6-15
+
+In order to provide a non-intrusive tanuki implementation for ``my_iface``, we need to implement
+a partial specialisation of the :cpp:struct:`iface_impl` struct for ``my_iface``:
+
+.. literalinclude:: ../tutorial/nonintrusive.cpp
+   :language: c++
+   :lines: 17-29
+
+Here we are specifying an implementation for all value types ``T``, but, as explained
+in previous tutorials, we could also easily provide a partially-specialised implementation,
+constrain the implementation only for value types modelling
+certain requirements, provide an empty default implementation, etc.
+
+We are now able to wrap ``my_iface`` in the usual way:
+
+.. literalinclude:: ../tutorial/nonintrusive.cpp
+   :language: c++
+   :lines: 31-40
+
+.. code-block:: console
+
+   The final answer is 42
+
+Full code listing
+-----------------
+
+.. literalinclude:: ../tutorial/nonintrusive.cpp
+    :language: c++
diff --git a/doc/tutorials.rst b/doc/tutorials.rst
index e81343a..f3ddb87 100644
--- a/doc/tutorials.rst
+++ b/doc/tutorials.rst
@@ -11,3 +11,4 @@ Tutorials
    wrap_reference.rst
    custom_construct.rst
    composite_interfaces.rst
+   nonintrusive.rst
diff --git a/doc/utils.rst b/doc/utils.rst
index 9748d31..baad29e 100644
--- a/doc/utils.rst
+++ b/doc/utils.rst
@@ -46,3 +46,13 @@ Utilities
    This concept detects if :cpp:type:`T` is a type that can be type-erased by a :cpp:class:`wrap`.
 
    :cpp:type:`T` must be a non-cv qualified destructible object.
+
+.. cpp:struct:: template <typename IFace, typename Base, typename Holder, typename T> iface_impl
+
+   Non-intrusive interface implementation.
+
+   This class can be partially specialised to specify a non-intrusive implementation for the interface :cpp:type:`IFace`.
+   See the :ref:`tutorial <nonintrusive>` for an example.
+
+   The unspecialised version of this class is an empty trivial structure which disables non-intrusive implementations
+   for the interface :cpp:type:`IFace`.
diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt
index bd33b94..be62a61 100644
--- a/test/CMakeLists.txt
+++ b/test/CMakeLists.txt
@@ -52,6 +52,7 @@ ADD_TANUKI_TESTCASE(test_make_invalid)
 ADD_TANUKI_TESTCASE(test_emplace)
 ADD_TANUKI_TESTCASE(test_invalid_composite)
 ADD_TANUKI_TESTCASE(test_std_function)
+ADD_TANUKI_TESTCASE(test_nonintrusive)
 
 ADD_TANUKI_TESTCASE(io_iterator)
 ADD_TANUKI_TESTCASE(input_iterator)
diff --git a/test/test_nonintrusive.cpp b/test/test_nonintrusive.cpp
new file mode 100644
index 0000000..665068d
--- /dev/null
+++ b/test/test_nonintrusive.cpp
@@ -0,0 +1,89 @@
+#include <concepts>
+
+#include <tanuki/tanuki.hpp>
+
+#include <catch2/catch_test_macros.hpp>
+
+// NOLINTBEGIN(cert-err58-cpp,misc-use-anonymous-namespace,cppcoreguidelines-avoid-do-while)
+
+namespace ns
+{
+
+struct my_iface {
+    virtual ~my_iface() = default;
+    virtual int foo() const = 0;
+};
+
+// Also include an interface with an impl template
+// to check the non-intrusive representation takes
+// the precedence.
+struct my_iface2 {
+    virtual ~my_iface2() = default;
+    virtual int bar() const = 0;
+
+    template <typename Base, typename Holder, typename T>
+    struct impl {
+    };
+};
+
+} // namespace ns
+
+namespace tanuki
+{
+
+// Empty default nonintrusive implementation.
+template <typename Base, typename Holder, typename T>
+struct iface_impl<ns::my_iface, Base, Holder, T> {
+};
+
+// Specialisation for int value type.
+template <typename Base, typename Holder>
+struct iface_impl<ns::my_iface, Base, Holder, int> : public Base {
+    int foo() const final
+    {
+        return 42;
+    }
+};
+
+template <typename Base, typename Holder, typename T>
+struct iface_impl<ns::my_iface2, Base, Holder, T> {
+};
+
+// Specialisation for int value type.
+template <typename Base, typename Holder>
+struct iface_impl<ns::my_iface2, Base, Holder, int> : public Base {
+    int bar() const final
+    {
+        return 43;
+    }
+};
+
+} // namespace tanuki
+
+TEST_CASE("basics")
+{
+    using wrap_t = tanuki::wrap<ns::my_iface>;
+
+    wrap_t w(123);
+    REQUIRE(w->foo() == 42);
+
+    REQUIRE(!std::constructible_from<wrap_t, long>);
+
+    using wrap2_t = tanuki::wrap<ns::my_iface2>;
+
+    wrap2_t w2(123);
+    REQUIRE(w2->bar() == 43);
+
+    REQUIRE(!std::constructible_from<wrap2_t, long>);
+
+    // Composite interface.
+    using wrap3_t = tanuki::wrap<tanuki::composite_iface<ns::my_iface, ns::my_iface2>>;
+
+    wrap3_t w3(123);
+    REQUIRE(w3->foo() == 42);
+    REQUIRE(w3->bar() == 43);
+
+    REQUIRE(!std::constructible_from<wrap3_t, long>);
+}
+
+// NOLINTEND(cert-err58-cpp,misc-use-anonymous-namespace,cppcoreguidelines-avoid-do-while)
diff --git a/tutorial/CMakeLists.txt b/tutorial/CMakeLists.txt
index b464424..071f028 100644
--- a/tutorial/CMakeLists.txt
+++ b/tutorial/CMakeLists.txt
@@ -26,4 +26,5 @@ ADD_TANUKI_TUTORIAL(wrap_reference)
 ADD_TANUKI_TUTORIAL(emplace)
 ADD_TANUKI_TUTORIAL(compose1)
 ADD_TANUKI_TUTORIAL(compose2)
+ADD_TANUKI_TUTORIAL(nonintrusive)
 ADD_TANUKI_TUTORIAL(std_function)
diff --git a/tutorial/nonintrusive.cpp b/tutorial/nonintrusive.cpp
new file mode 100644
index 0000000..08bded8
--- /dev/null
+++ b/tutorial/nonintrusive.cpp
@@ -0,0 +1,40 @@
+#include <iostream>
+#include <string>
+
+#include <tanuki/tanuki.hpp>
+
+namespace ns
+{
+
+// An existing OO interface.
+struct my_iface {
+    virtual ~my_iface() = default;
+    virtual int foo() const = 0;
+};
+
+} // namespace ns
+
+namespace tanuki
+{
+
+// Non-intrusive implementation for the ns::my_iface interface.
+template <typename Base, typename Holder, typename T>
+struct iface_impl<ns::my_iface, Base, Holder, T> : public Base {
+    int foo() const override
+    {
+        return 42;
+    }
+};
+
+} // namespace tanuki
+
+int main()
+{
+    // Define a wrap for ns::my_iface.
+    using wrap_t = tanuki::wrap<ns::my_iface>;
+
+    wrap_t w1(123);
+    wrap_t w2(std::string("hello world!"));
+
+    std::cout << "The final answer is " << w1->foo() << '\n';
+}