diff --git a/arborio/CMakeLists.txt b/arborio/CMakeLists.txt
index 4caba22857fb6fc0f77cb1dc5d45fa8f51d036f7..14522ad6253fad35e13f61b5715999e2644f2a3a 100644
--- a/arborio/CMakeLists.txt
+++ b/arborio/CMakeLists.txt
@@ -5,9 +5,9 @@ set(arborio-sources
 )
 if(ARB_WITH_NEUROML)
     list(APPEND arborio-sources
-            arbornml.cpp
-            parse_morphology.cpp
-            with_xml.cpp
+            neuroml.cpp
+            nml_parse_morphology.cpp
+            xml.cpp
             xmlwrap.cpp
         )
     find_package(LibXml2 REQUIRED)
diff --git a/arborio/include/arborio/arbornml.hpp b/arborio/include/arborio/neuroml.hpp
similarity index 75%
rename from arborio/include/arborio/arbornml.hpp
rename to arborio/include/arborio/neuroml.hpp
index 0f3d939e5049450245754201889eeabcd12abfda..401841d37093e0e4cda891785fbdd07de7d4981e 100644
--- a/arborio/include/arborio/arbornml.hpp
+++ b/arborio/include/arborio/neuroml.hpp
@@ -20,21 +20,14 @@ struct neuroml_exception: std::runtime_error {
     {}
 };
 
-// Generic XML error (as reported by libxml2).
-struct xml_error: neuroml_exception {
-    xml_error(const std::string& xml_error_msg, unsigned line = 0);
-    std::string xml_error_msg;
-    unsigned line;
-};
-
 // Can't parse NeuroML if we don't have a document.
-struct no_document: neuroml_exception {
-    no_document();
+struct nml_no_document: neuroml_exception {
+    nml_no_document();
 };
 
 // Generic error parsing NeuroML data.
-struct parse_error: neuroml_exception {
-    parse_error(const std::string& error_msg, unsigned line = 0);
+struct nml_parse_error: neuroml_exception {
+    nml_parse_error(const std::string& error_msg, unsigned line = 0);
     std::string error_msg;
     unsigned line;
 };
@@ -42,24 +35,24 @@ struct parse_error: neuroml_exception {
 // NeuroML morphology error: improper segment data, e.g. bad id specification,
 // segment parent does not exist, fractionAlong is out of bounds, missing
 // required <proximal> data.
-struct bad_segment: neuroml_exception {
-    bad_segment(unsigned long long segment_id, unsigned line = 0);
+struct nml_bad_segment: neuroml_exception {
+    nml_bad_segment(unsigned long long segment_id, unsigned line = 0);
     unsigned long long segment_id;
     unsigned line;
 };
 
 // NeuroML morphology error: improper segmentGroup data, e.g. malformed
 // element data, missing referenced segments or groups, etc.
-struct bad_segment_group: neuroml_exception {
-    bad_segment_group(const std::string& group_id, unsigned line = 0);
+struct nml_bad_segment_group: neuroml_exception {
+    nml_bad_segment_group(const std::string& group_id, unsigned line = 0);
     std::string group_id;
     unsigned line;
 };
 
 // A segment or segmentGroup ultimately refers to itself via `parent`
 // or `include` elements respectively.
-struct cyclic_dependency: neuroml_exception {
-    cyclic_dependency(const std::string& id, unsigned line = 0);
+struct nml_cyclic_dependency: neuroml_exception {
+    nml_cyclic_dependency(const std::string& id, unsigned line = 0);
     std::string id;
     unsigned line;
 };
@@ -70,7 +63,7 @@ struct cyclic_dependency: neuroml_exception {
 // Note: segment id values are interpreted as unsigned long long values;
 // parsing larger segment ids will throw an exception.
 
-struct morphology_data {
+struct nml_morphology_data {
     // Cell id, or empty if morphology was taken from a top-level <morphology> element.
     std::optional<std::string> cell_id;
 
@@ -115,8 +108,8 @@ struct neuroml {
     // Parse and retrieve top-level morphology or morphology associated with a cell.
     // Return nullopt if not found.
 
-    std::optional<morphology_data> morphology(const std::string& morph_id) const;
-    std::optional<morphology_data> cell_morphology(const std::string& cell_id) const;
+    std::optional<nml_morphology_data> morphology(const std::string& morph_id) const;
+    std::optional<nml_morphology_data> cell_morphology(const std::string& cell_id) const;
 
     ~neuroml();
 
diff --git a/arborio/include/arborio/with_xml.hpp b/arborio/include/arborio/xml.hpp
similarity index 61%
rename from arborio/include/arborio/with_xml.hpp
rename to arborio/include/arborio/xml.hpp
index d176daa00413a182869fcec94aa61d12de45e42a..702827c3c9cdd57b48389ac7400f091ace91130c 100644
--- a/arborio/include/arborio/with_xml.hpp
+++ b/arborio/include/arborio/xml.hpp
@@ -1,13 +1,25 @@
 #pragma once
 
+#include <stdexcept>
+#include <string>
+
+// XML related interfaces deriving from the underlying XML implementation library.
+
+namespace arborio {
+
+// Generic XML error (as reported by libxml2).
+struct xml_error: std::runtime_error {
+    xml_error(const std::string& xml_error_msg, unsigned line = 0);
+    std::string xml_error_msg;
+    unsigned line;
+};
+
 // Wrap initialization and cleanup of libxml2 library.
 //
 // Use of `with_xml` is only necessary if arborio is being
 // used in a multithreaded context and the client code is
 // not managing libxml2 initialization and cleanup.
 
-namespace arborio {
-
 struct with_xml {
     with_xml();
     ~with_xml();
diff --git a/arborio/neurolucida.cpp b/arborio/neurolucida.cpp
index 705082d51b6443dcd5b5ea142712654b1a9b96f1..4bccd0dbb16f1c40e3472b89a9899fa00b99b6dc 100644
--- a/arborio/neurolucida.cpp
+++ b/arborio/neurolucida.cpp
@@ -26,6 +26,10 @@ asc_unsupported::asc_unsupported(const std::string& error_msg):
     message(error_msg)
 {}
 
+
+namespace {
+// Parse functions and internal representations kept in unnamed namespace.
+
 struct parse_error {
     struct cpp_info {
         const char* file;
@@ -513,6 +517,8 @@ parse_hopefully<sub_tree> parse_sub_tree(asc::lexer& L) {
     return tree;
 }
 
+} // namespace
+
 
 // Perform the parsing of the input as a string.
 asc_morphology parse_asc_string(const char* input) {
diff --git a/arborio/arbornml.cpp b/arborio/neuroml.cpp
similarity index 76%
rename from arborio/arbornml.cpp
rename to arborio/neuroml.cpp
index 0ee14b0814f425eed81c281da00628660ec05fc9..63e1bfc0943ce3cb047a2f147133be29a056a8a3 100644
--- a/arborio/arbornml.cpp
+++ b/arborio/neuroml.cpp
@@ -3,37 +3,33 @@
 #include <string>
 #include <vector>
 
-#include <arborio/arbornml.hpp>
+#include <arborio/neuroml.hpp>
 
-#include "parse_morphology.hpp"
+#include "nml_parse_morphology.hpp"
 #include "xmlwrap.hpp"
 
 using std::optional;
 using std::nullopt;
 
+using namespace arborio::xmlwrap;
+
 namespace arborio {
 
 static std::string fmt_error(const char* prefix, const std::string& err, unsigned line) {
     return prefix + (line==0? err: "line " + std::to_string(line) + ": " + err);
 }
 
-xml_error::xml_error(const std::string& xml_error_msg, unsigned line):
-    neuroml_exception(fmt_error("xml error: ", xml_error_msg, line)),
-    xml_error_msg(xml_error_msg),
-    line(line)
-{}
-
-no_document::no_document():
+nml_no_document::nml_no_document():
     neuroml_exception("no NeuroML document to parse")
 {}
 
-parse_error::parse_error(const std::string& error_msg, unsigned line):
+nml_parse_error::nml_parse_error(const std::string& error_msg, unsigned line):
     neuroml_exception(fmt_error("parse error: ", error_msg, line)),
     error_msg(error_msg),
     line(line)
 {}
 
-bad_segment::bad_segment(unsigned long long segment_id, unsigned line):
+nml_bad_segment::nml_bad_segment(unsigned long long segment_id, unsigned line):
     neuroml_exception(
         fmt_error(
             "bad morphology segment: ",
@@ -43,7 +39,7 @@ bad_segment::bad_segment(unsigned long long segment_id, unsigned line):
     line(line)
 {}
 
-bad_segment_group::bad_segment_group(const std::string& group_id, unsigned line):
+nml_bad_segment_group::nml_bad_segment_group(const std::string& group_id, unsigned line):
     neuroml_exception(
         fmt_error(
             "bad morphology segmentGroup: ",
@@ -53,7 +49,7 @@ bad_segment_group::bad_segment_group(const std::string& group_id, unsigned line)
     line(line)
 {}
 
-cyclic_dependency::cyclic_dependency(const std::string& id, unsigned line):
+nml_cyclic_dependency::nml_cyclic_dependency(const std::string& id, unsigned line):
     neuroml_exception(
         fmt_error(
             "cyclic dependency: ",
@@ -74,7 +70,7 @@ struct neuroml_impl {
     }
 
     xml_xpathctx make_context() const {
-        if (!doc) throw no_document{};
+        if (!doc) throw nml_no_document{};
 
         auto ctx = xpath_context(doc);
         ctx.register_ns("nml", "http://www.neuroml.org/schema/neuroml2");
@@ -120,15 +116,15 @@ std::vector<std::string> neuroml::morphology_ids() const {
     return result;
 }
 
-optional<morphology_data> neuroml::morphology(const std::string& morph_id) const {
+optional<nml_morphology_data> neuroml::morphology(const std::string& morph_id) const {
     xml_error_scope err;
     auto ctx = impl_->make_context();
     auto matches = ctx.query("//nml:neuroml/nml:morphology[@id="+xpath_escape(morph_id)+"]");
 
-    return matches.empty()? nullopt: optional(parse_morphology_element(ctx, matches[0]));
+    return matches.empty()? nullopt: optional(nml_parse_morphology_element(ctx, matches[0]));
 }
 
-optional<morphology_data> neuroml::cell_morphology(const std::string& cell_id) const {
+optional<nml_morphology_data> neuroml::cell_morphology(const std::string& cell_id) const {
     xml_error_scope err;
     auto ctx = impl_->make_context();
     auto matches = ctx.query(
@@ -137,7 +133,7 @@ optional<morphology_data> neuroml::cell_morphology(const std::string& cell_id) c
 
     if (matches.empty()) return nullopt;
 
-    morphology_data M = parse_morphology_element(ctx, matches[0]);
+    nml_morphology_data M = nml_parse_morphology_element(ctx, matches[0]);
     M.cell_id = cell_id;
     return M;
 }
diff --git a/arborio/parse_morphology.cpp b/arborio/nml_parse_morphology.cpp
similarity index 82%
rename from arborio/parse_morphology.cpp
rename to arborio/nml_parse_morphology.cpp
index d7adc67a7a1edc28aa64d0a601322e01ba37ce8b..baff719a928b88cfde9e1c1d2bed1fb22897cfa2 100644
--- a/arborio/parse_morphology.cpp
+++ b/arborio/nml_parse_morphology.cpp
@@ -14,9 +14,9 @@
 #include <arbor/morph/stitch.hpp>
 #include <arbor/util/expected.hpp>
 
-#include <arborio/arbornml.hpp>
+#include <arborio/neuroml.hpp>
 
-#include "parse_morphology.hpp"
+#include "nml_parse_morphology.hpp"
 #include "xmlwrap.hpp"
 
 using std::optional;
@@ -24,8 +24,14 @@ using arb::region;
 using arb::util::expected;
 using arb::util::unexpected;
 
+using namespace std::literals;
+using namespace arborio::xmlwrap;
+
 namespace arborio {
 
+// Implementation utility classes:
+
+namespace {
 // Box is a container of size 0 or 1.
 
 template <typename X>
@@ -126,6 +132,25 @@ expected<std::vector<std::size_t>, cycle_detected> topological_sort(std::size_t
     return depth;
 }
 
+template <typename T>
+struct propx {
+    explicit propx(xml_node n, const char* attr, optional<T> dflt = std::nullopt) {
+        if (auto x = n.prop<T>(attr, dflt)) {
+            result_ = std::move(x.value());
+        }
+        else {
+             throw nml_parse_error(x.error().error, x.error().line);
+        }
+    }
+
+    operator T() && { return std::move(result_); }
+    operator T() const& { return result_; }
+    T result_;
+};
+
+} // namespace
+
+
 // Internal representations of NeuroML segment and segmentGroup data:
 
 struct neuroml_segment {
@@ -201,14 +226,14 @@ struct neuroml_segment_tree {
         // Build index, throw on duplicate id.
         for (std::size_t i = 0; i<n_seg; ++i) {
             if (!index_.insert({segments_[i].id, i}).second) {
-                throw bad_segment(segments_[i].id, segments_[i].line);
+                throw nml_bad_segment(segments_[i].id, segments_[i].line);
             }
         }
 
         // Check parent relationship is sound.
         for (const auto& s: segments_) {
             if (s.parent_id && !index_.count(*s.parent_id)) {
-                throw bad_segment(s.id, s.line); // No such parent id.
+                throw nml_bad_segment(s.id, s.line); // No such parent id.
             }
         }
 
@@ -225,12 +250,12 @@ struct neuroml_segment_tree {
         }
         else {
             const auto& seg = segments_[depths.error().index];
-            throw cyclic_dependency(nl_to_string(seg.id), seg.line);
+            throw nml_cyclic_dependency(nl_to_string(seg.id), seg.line);
         }
         std::sort(segments_.begin(), segments_.end(), [](auto& a, auto& b) { return a.tdepth<b.tdepth; });
 
         // Check for multiple roots:
-        if (n_seg>1 && segments_[1].tdepth==0) throw bad_segment(segments_[1].id, segments_[1].line);
+        if (n_seg>1 && segments_[1].tdepth==0) throw nml_bad_segment(segments_[1].id, segments_[1].line);
 
         // Update index:
         for (std::size_t i = 0; i<n_seg; ++i) {
@@ -251,7 +276,7 @@ private:
     std::unordered_map<non_negative, std::vector<non_negative>> children_;
 };
 
-std::unordered_map<std::string, std::vector<non_negative>> evaluate_segment_groups(
+static std::unordered_map<std::string, std::vector<non_negative>> evaluate_segment_groups(
     std::vector<neuroml_segment_group_info> groups,
     const neuroml_segment_tree& segtree)
 {
@@ -300,7 +325,7 @@ std::unordered_map<std::string, std::vector<non_negative>> evaluate_segment_grou
             }
         }
         catch (...) {
-            throw bad_segment_group(g.id, line);
+            throw nml_bad_segment_group(g.id, line);
         }
     }
 
@@ -308,7 +333,7 @@ std::unordered_map<std::string, std::vector<non_negative>> evaluate_segment_grou
     std::unordered_map<std::string, std::size_t> index;
     for (std::size_t i = 0; i<n_group; ++i) {
         if (!index.insert({groups[i].id, i}).second) {
-            throw bad_segment_group(groups[i].id, groups[i].line);
+            throw nml_bad_segment_group(groups[i].id, groups[i].line);
         }
     }
 
@@ -318,7 +343,7 @@ std::unordered_map<std::string, std::vector<non_negative>> evaluate_segment_grou
         const auto& includes = groups[i].includes;
         index_to_included_indices[i].reserve(includes.size());
         for (auto& id: includes) {
-            if (!index.count(id)) throw bad_segment_group(groups[i].id, groups[i].line);
+            if (!index.count(id)) throw nml_bad_segment_group(groups[i].id, groups[i].line);
             index_to_included_indices[i].push_back(index.at(id));
         }
     }
@@ -332,7 +357,7 @@ std::unordered_map<std::string, std::vector<non_negative>> evaluate_segment_grou
     }
     else {
         const auto& group = groups[depths.error().index];
-        throw cyclic_dependency(group.id, group.line);
+        throw nml_cyclic_dependency(group.id, group.line);
     }
 
     // Accumulate included group segments, following topological order.
@@ -363,7 +388,7 @@ std::unordered_map<std::string, std::vector<non_negative>> evaluate_segment_grou
     return group_seg_map;
 }
 
-arb::stitched_morphology construct_morphology(const neuroml_segment_tree& segtree) {
+static arb::stitched_morphology construct_morphology(const neuroml_segment_tree& segtree) {
     arb::stitch_builder builder;
     if (segtree.empty()) return arb::stitched_morphology{builder};
 
@@ -384,9 +409,9 @@ arb::stitched_morphology construct_morphology(const neuroml_segment_tree& segtre
     return arb::stitched_morphology(std::move(builder));
 }
 
-morphology_data parse_morphology_element(xml_xpathctx ctx, xml_node morph) {
-    morphology_data M;
-    M.id = morph.prop<std::string>("id", std::string{});
+nml_morphology_data nml_parse_morphology_element(xml_xpathctx ctx, xml_node morph) {
+    nml_morphology_data M;
+    M.id = propx<std::string>(morph, "id", ""s);
 
     std::vector<neuroml_segment> segments;
 
@@ -401,47 +426,47 @@ morphology_data parse_morphology_element(xml_xpathctx ctx, xml_node morph) {
 
         try {
             seg.id = -1;
-            seg.id = n.prop<non_negative>("id");
-            std::string name = n.prop<std::string>("name", std::string{});
+            seg.id = propx<non_negative>(n, "id");
+            std::string name = propx<std::string>(n, "name", ""s);
 
             auto result = ctx.query(n, q_parent);
             if (!result.empty()) {
                 line = result[0].line();
-                seg.parent_id = result[0].prop<non_negative>("segment");
-                seg.along = result[0].prop<double>("fractionAlong", 1.0);
+                seg.parent_id = propx<non_negative>(result[0], "segment");
+                seg.along = propx<double>(result[0], "fractionAlong", 1.0);
             }
 
             result = ctx.query(n, q_proximal);
             if (!result.empty()) {
                 line = result[0].line();
-                double x = result[0].prop<double>("x");
-                double y = result[0].prop<double>("y");
-                double z = result[0].prop<double>("z");
-                double diameter = result[0].prop<double>("diameter");
-                if (diameter<0) throw bad_segment(seg.id, n.line());
+                double x = propx<double>(result[0], "x");
+                double y = propx<double>(result[0], "y");
+                double z = propx<double>(result[0], "z");
+                double diameter = propx<double>(result[0], "diameter");
+                if (diameter<0) throw nml_bad_segment(seg.id, n.line());
 
                 seg.proximal = arb::mpoint{x, y, z, diameter/2};
             }
 
-            if (!seg.parent_id && !seg.proximal) throw bad_segment(seg.id, n.line());
+            if (!seg.parent_id && !seg.proximal) throw nml_bad_segment(seg.id, n.line());
 
             result = ctx.query(n, q_distal);
             if (!result.empty()) {
                 line = result[0].line();
-                double x = result[0].prop<double>("x");
-                double y = result[0].prop<double>("y");
-                double z = result[0].prop<double>("z");
-                double diameter = result[0].prop<double>("diameter");
-                if (diameter<0) throw bad_segment(seg.id, n.line());
+                double x = propx<double>(result[0], "x");
+                double y = propx<double>(result[0], "y");
+                double z = propx<double>(result[0], "z");
+                double diameter = propx<double>(result[0], "diameter");
+                if (diameter<0) throw nml_bad_segment(seg.id, n.line());
 
                 seg.distal = arb::mpoint{x, y, z, diameter/2};
             }
             else {
-                throw bad_segment(seg.id, n.line());
+                throw nml_bad_segment(seg.id, n.line());
             }
         }
-        catch (parse_error& e) {
-            throw bad_segment(seg.id, line);
+        catch (nml_parse_error& e) {
+            throw nml_bad_segment(seg.id, line);
         }
 
         seg.line = n.line();
@@ -468,16 +493,16 @@ morphology_data parse_morphology_element(xml_xpathctx ctx, xml_node morph) {
         int line = n.line(); // for error context!
 
         try {
-            group.id = n.prop<std::string>("id");
+            group.id = propx<std::string>(n, "id");
             for (auto elem: ctx.query(n, q_member)) {
                 line = elem.line();
-                auto seg_id = elem.prop<non_negative>("segment");
-                if (!segtree.contains(seg_id)) throw bad_segment_group(group.id, line);
-                group.segments.push_back(elem.prop<non_negative>("segment"));
+                auto seg_id = propx<non_negative>(elem, "segment");
+                if (!segtree.contains(seg_id)) throw nml_bad_segment_group(group.id, line);
+                group.segments.push_back(propx<non_negative>(elem, "segment"));
             }
             for (auto elem: ctx.query(n, q_include)) {
                 line = elem.line();
-                group.includes.push_back(elem.prop<std::string>("segmentGroup"));
+                group.includes.push_back(propx<std::string>(elem, "segmentGroup"));
             }
 
             // Treat `<path>` and `<subTree>` identically:
@@ -490,11 +515,11 @@ morphology_data parse_morphology_element(xml_xpathctx ctx, xml_node morph) {
                 sub.line = line;
                 if (!froms.empty()) {
                     line = froms[0].line();
-                    sub.from = froms[0].template prop<non_negative>("segment");
+                    sub.from = propx<non_negative>(froms[0], "segment");
                 }
                 if (!tos.empty()) {
                     line = tos[0].line();
-                    sub.to = tos[0].template prop<non_negative>("segment");
+                    sub.to = propx<non_negative>(tos[0], "segment");
                 }
 
                 return sub;
@@ -507,8 +532,8 @@ morphology_data parse_morphology_element(xml_xpathctx ctx, xml_node morph) {
                 group.subtrees.push_back(parse_subtree_elem(elem));
             }
         }
-        catch (parse_error& e) {
-            throw bad_segment_group(group.id, line);
+        catch (nml_parse_error& e) {
+            throw nml_bad_segment_group(group.id, line);
         }
 
         group.line = n.line();
diff --git a/arborio/nml_parse_morphology.hpp b/arborio/nml_parse_morphology.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..3d4b5792c120d458d3c183873ff6a2793ba05240
--- /dev/null
+++ b/arborio/nml_parse_morphology.hpp
@@ -0,0 +1,10 @@
+#pragma once
+
+#include <arborio/neuroml.hpp>
+#include "xmlwrap.hpp"
+
+namespace arborio {
+
+nml_morphology_data nml_parse_morphology_element(xmlwrap::xml_xpathctx ctx, xmlwrap::xml_node morph);
+
+} // namespace arborio
diff --git a/arborio/parse_morphology.hpp b/arborio/parse_morphology.hpp
deleted file mode 100644
index 0ced56cd683067a4ebc12fc94c9fafdd82fa6354..0000000000000000000000000000000000000000
--- a/arborio/parse_morphology.hpp
+++ /dev/null
@@ -1,10 +0,0 @@
-#pragma once
-
-#include <arborio/arbornml.hpp>
-#include "xmlwrap.hpp"
-
-namespace arborio {
-
-morphology_data parse_morphology_element(xml_xpathctx ctx, xml_node morph);
-
-} // namespace arborio
diff --git a/arborio/with_xml.cpp b/arborio/xml.cpp
similarity index 53%
rename from arborio/with_xml.cpp
rename to arborio/xml.cpp
index 269c174d31b6da67a4f2fd841150ad5065079f8a..6e8a19e4a37722290405dc1e39e2dd6b2e928f87 100644
--- a/arborio/with_xml.cpp
+++ b/arborio/xml.cpp
@@ -1,9 +1,20 @@
-#include <arborio/with_xml.hpp>
+#include <stdexcept>
+#include <string>
 
 #include <libxml/parser.h>
 
+#include <arborio/xml.hpp>
+
+// Implementations for exposed libxml2 interfaces.
+
 namespace arborio {
 
+xml_error::xml_error(const std::string& xml_error_msg, unsigned line):
+    std::runtime_error(std::string("xml error: ") + (line? "line " + std::to_string(line): "") + xml_error_msg),
+    xml_error_msg(xml_error_msg),
+    line(line)
+{}
+
 with_xml::with_xml(): run_cleanup_(true) {
     // Initialize before any multithreaded access by library or client code.
     xmlInitParser();
diff --git a/arborio/xmlwrap.cpp b/arborio/xmlwrap.cpp
index 126f2ca6f1ff740cfbc4215c7e63e2a9f1efcafb..6f791b2b922d4d34d1f498b014c9733c3093dd49 100644
--- a/arborio/xmlwrap.cpp
+++ b/arborio/xmlwrap.cpp
@@ -15,6 +15,7 @@
 #include "xmlwrap.hpp"
 
 namespace arborio {
+namespace xmlwrap {
 
 namespace detail {
 
@@ -123,4 +124,5 @@ xml_error_scope::~xml_error_scope() {
     xmlStructuredErrorContext = structured_context_;
 }
 
+} // namespace xmlwrap
 } // namespace arborio
diff --git a/arborio/xmlwrap.hpp b/arborio/xmlwrap.hpp
index 0993b9ba0665d5f7002c614ef416a69cea007667..a95942a9171af76700ace4b66d033a7ee377026c 100644
--- a/arborio/xmlwrap.hpp
+++ b/arborio/xmlwrap.hpp
@@ -14,9 +14,16 @@
 #include <libxml/xpath.h>
 #include <libxml/xpathInternals.h>
 
-#include "arborio/arbornml.hpp"
+#include <arbor/util/expected.hpp>
+#include <arborio/xml.hpp>
 
 namespace arborio {
+namespace xmlwrap {
+
+struct bad_property {
+    std::string error;
+    unsigned line = 0;
+};
 
 // `non_negative` represents the corresponding constraint in the schema, which
 // can mean any arbitrarily large non-negtative integer value.
@@ -101,14 +108,18 @@ struct xml_node: protected xml_base<xmlNode>  {
     bool has_prop(const char* name) const { return xmlHasProp(get(), (const xmlChar*)name); }
 
     template <typename T>
-    T prop(const char* name, std::optional<T> default_value = std::nullopt) const {
+    arb::util::expected<T, bad_property> prop(const char* name, std::optional<T> default_value = std::nullopt) const {
+        using arb::util::unexpected;
+
         xmlChar* c = xmlGetProp(get(), (const xmlChar*)(name));
         if (!c) {
-            return default_value? default_value.value(): throw parse_error("missing required attribute", get()->line);
+            if (default_value) return default_value.value();
+            else return unexpected(bad_property{"missing required attribute", get()->line});
         }
 
         T v;
-        return nl_from_cstr(v, reinterpret_cast<const char*>(c))? v: throw parse_error("attribute type error", get()->line);
+        if (nl_from_cstr(v, reinterpret_cast<const char*>(c))) return v;
+        else return unexpected(bad_property{"attribute type error", get()->line});
     }
 
     using base::get; // (unsafe access)
@@ -314,4 +325,5 @@ struct xml_error_scope {
     void* structured_context_;
 };
 
+} // namespace xmlwrap
 } // namespace arborio
diff --git a/doc/cpp/morphology.rst b/doc/cpp/morphology.rst
index 20b0a46fa96dcc960bb33121d7e5e4252a005788..80ab858ef394c416c2111db3be834776491a5698 100644
--- a/doc/cpp/morphology.rst
+++ b/doc/cpp/morphology.rst
@@ -334,14 +334,14 @@ CV will have an extent on the branch longer than ``max_extent`` micrometres.
 
 
 Supported morphology formats
-----------------------------
+============================
 
 Arbor supports morphologies described using the SWC file format and the NeuroML file format.
 
 .. _cppswc:
 
 SWC
-^^^
+---
 
 Arbor supports reading morphologies described using the
 `SWC <http://www.neuronland.org/NLMorphologyConverter/MorphologyFormats/SWC/Spec.html>`_ file format. And
@@ -415,13 +415,14 @@ basic checks performed on them. The :cpp:type:`swc_data` object can then be used
 
    Returns a :cpp:type:`morphology` constructed according to NEURON's SWC specifications.
 
+
 .. _cppasc:
 
 Neurolucida ASCII
-^^^^^^^^^^^^^^^^^^
+-----------------
 
 Arbor supports reading morphologies described using the
-:ref:`Neurolucida <format_asc>`_ file format.
+:ref:`Neurolucida ASCII file format <formatasc>`.
 
 The :cpp:func:`parse_asc()` function is used to parse the SWC file and generate a :cpp:type:`asc_morphology` object,
 which a simple struct with two members representing the morphology and a label dictionary with labeled
@@ -438,38 +439,36 @@ regions and locations.
    Parse a Neurolucida ASCII file.
    Throws an exception if there is an error parsing the file.
 
+
 .. _cppneuroml:
 
 NeuroML
-^^^^^^^
+-------
 
-Arbor offers limited support for models described in
-`NeuroML version 2 <https://neuroml.org/neuromlv2>`_.
-This is not built by default, but can be enabled by
-providing the `-DARB_NEUROML=ON` argument to CMake at
-configuration time (see :ref:`install-neuroml`). This will
-build the ``arborio`` libray with neuroml support.
+Arbor offers limited support for models described in `NeuroML version 2
+<https://neuroml.org/neuromlv2>`_. This is not built by default, but can be
+enabled by providing the `-DARB_WITH_NEUROML=ON` argument to CMake at configuration
+time (see :ref:`install-neuroml`). This will build the ``arborio`` libray with
+neuroml support.
 
-The ``arborio`` library uses `libxml2 <http://xmlsoft.org/>`_
-for XML parsing. Applications using NeuroML through ``arborio``
-will need to link against ``libxml2`` in addition, though this
-is performed implicitly within CMake projects that add ``arbor::arborio``
-as a link library.
+The ``arborio`` library uses `libxml2 <http://xmlsoft.org/>`_ for XML parsing.
+Applications using NeuroML through ``arborio`` will need to link against
+``libxml2`` in addition, though this is performed implicitly within CMake
+projects that add ``arbor::arborio`` as a link library.
 
-All classes and functions provided by the ``arborio`` library
-are provided in the ``arborio`` namespace.
+All classes and functions provided by the ``arborio`` library are provided in
+the ``arborio`` namespace.
 
 Libxml2 interface
-=================
+^^^^^^^^^^^^^^^^^
 
-Libxml2 offers threadsafe XML parsing, but not by default. If
-the application uses NeuromML support from ``arborio`` in an
-unthreaded context, or has already explicitly initialized ``libxml2``,
-nothing more needs to be done. Otherwise, the ``libxml2`` function
-``xmlInitParser()`` must be called explicitly.
+Libxml2 offers threadsafe XML parsing, but not by default. If the application
+uses NeuromML support from ``arborio`` in an unthreaded context, or has already
+explicitly initialized ``libxml2``, nothing more needs to be done. Otherwise,
+the ``libxml2`` function ``xmlInitParser()`` must be called explicitly.
 
 ``arborio`` provides a helper guard object for this purpose, defined
-in ``arborio/with_xml.hpp``:
+in ``arborio/xml.hpp``:
 
 .. cpp:namespace:: arborio
 
@@ -478,16 +477,25 @@ in ``arborio/with_xml.hpp``:
    An RAII guard object that calls ``xmlInitParser()`` upon construction, and
    ``xmlCleanupParser()`` upon destruction. The constructor takes no parameters.
 
+Unhandleable exceptions from ``libxml2`` are forwarded via an exception
+``xml_error``, derived from ``std::runtime_error``.
+
 NeuroML2 morphology support
-===========================
+^^^^^^^^^^^^^^^^^^^^^^^^^^^
 
 NeuroML documents are represented by the ``arborio::neuroml`` class,
 which in turn provides methods for the identification and translation
-of morphology data. ``neuroml`` objects are moveable and move-assignable, but not copyable.
+of morphology data. ``neuroml`` objects are moveable and move-assignable,
+but not copyable.
 
 An implementation limitation restricts valid segment id values to
 those which can be represented by an ``unsigned long long`` value.
 
+``arborio::neuroml`` methods can throw an ``arborio::xml_error`` in the instance that
+the underlying libxml2 library reports a problem that cannot be handled by the ``arborio``
+library. Otherwise, exceptions derived from ``aborio::neuroml_exception`` can be thrown
+when encountering problems interpreting the NeuroML document (see :ref:`cppneuromlexceptions` below).
+
 .. cpp:class:: neuroml
 
    .. cpp:function:: neuroml(std::string)
@@ -502,24 +510,22 @@ those which can be represented by an ``unsigned long long`` value.
 
    Return the id of each top-level ``<morphology>`` element defined in the NeuroML document.
 
-   .. cpp:function:: std::optional<morphology_data> morphology(const std::string&) const
+   .. cpp:function:: std::optional<nml_morphology_data> morphology(const std::string&) const
 
    Return a representation of the top-level morphology with the supplied identifier, or
-   ``std::nullopt`` if no such morphology could be found. Parse errors or an inconsistent
-   representation will raise an exception derived from ``neuroml_exception``.
+   ``std::nullopt`` if no such morphology could be found.
 
-   .. cpp:function:: std::optional<morphology_data> cell_morphology(const std::string&) const
+   .. cpp:function:: std::optional<nml_morphology_data> cell_morphology(const std::string&) const
 
    Return a representation of the morphology associated with the cell with the supplied identifier,
-   or ``std::nullopt`` if the cell or its morphology could not be found. Parse errors or an
-   inconsistent representation will raise an exception derived from ``neuroml_exception``.
+   or ``std::nullopt`` if the cell or its morphology could not be found.
 
 The morphology representation contains the corresponding Arbor ``arb::morphology`` object,
 label dictionaries for regions corresponding to its segments and segment groups by name
 and id, and a map providing the explicit list of segments contained within each defined
 segment group.
 
-.. cpp:class:: morphology_data
+.. cpp:class:: nml_morphology_data
 
    .. cpp:member:: std::optional<std::string> cell_id
 
@@ -551,39 +557,37 @@ segment group.
    A map from each segment group id to its corresponding collection of segments.
 
 
+.. _cppneuromlexceptions:
+
 Exceptions
-==========
+^^^^^^^^^^
 
-All NeuroML-specific exceptions are defined in ``arborio/arbornml.hpp``, and are
+All NeuroML-specific exceptions are defined in ``arborio/neuroml.hpp``, and are
 derived from ``arborio::neuroml_exception`` which in turn is derived from ``std::runtime_error``.
-With the exception of the ``no_document`` exception, all contain an unsigned member ``line``
+With the exception of the ``nml_no_document`` exception, all contain an unsigned member ``line``
 which is intended to identify the problematic construct within the document.
 
-.. cpp:class:: xml_error: neuroml_exception
-
-   A generic XML error generated by the ``libxml2`` library.
-
-.. cpp:class:: no_document: neuroml_exception
+.. cpp:class:: nml_no_document: neuroml_exception
 
-   A request was made on an :cpp:class:`neuroml` document without any content.
+   A request was made to parse text which could not be interpreted as an XML document.
 
-.. cpp:class:: parse_error: neuroml_exception
+.. cpp:class:: nml_parse_error: neuroml_exception
 
    Failure parsing an element or attribute in the NeuroML document. These
    can be generated if the document does not confirm to the NeuroML2 schema,
    for example.
 
-.. cpp:class:: bad_segment: neuroml_exception
+.. cpp:class:: nml_bad_segment: neuroml_exception
 
    A ``<segment>`` element has an improper ``id`` attribue, refers to a non-existent
    parent, is missing a required parent or proximal element, or otherwise is missing
    a mandatory child element or has a malformed child element.
 
-.. cpp:class:: bad_segment_group: neuroml_exception
+.. cpp:class:: nml_bad_segment_group: neuroml_exception
 
    A ``<segmentGroup>`` element has a malformed child element or references
    a non-existent segment group or segment.
 
-.. cpp:class:: cyclic_dependency: neuroml_exception
+.. cpp:class:: nml_cyclic_dependency: neuroml_exception
 
    A segment or segment group ultimately refers to itself via ``parent``
diff --git a/python/morphology.cpp b/python/morphology.cpp
index d15457855ddcad2ddd237941704999d9cacbd414..8c736369df5e89485c381ae8c8d4aca13eb4bef8 100644
--- a/python/morphology.cpp
+++ b/python/morphology.cpp
@@ -16,7 +16,7 @@
 #include <arborio/neurolucida.hpp>
 
 #ifdef ARB_NEUROML_ENABLED
-#include <arborio/arbornml.hpp>
+#include <arborio/neuroml.hpp>
 #endif
 
 #include "error.hpp"
@@ -398,28 +398,28 @@ void register_morphology(py::module& m) {
 
 #ifdef ARB_NEUROML_ENABLED
     // arborio::morphology_data
-    py::class_<arborio::morphology_data> nml_morph_data(m, "neuroml_morph_data");
+    py::class_<arborio::nml_morphology_data> nml_morph_data(m, "neuroml_morph_data");
     nml_morph_data
         .def_readonly("cell_id",
-            &arborio::morphology_data::cell_id,
+            &arborio::nml_morphology_data::cell_id,
             "Cell id, or empty if morphology was taken from a top-level <morphology> element.")
         .def_readonly("id",
-            &arborio::morphology_data::id,
+            &arborio::nml_morphology_data::id,
             "Morphology id.")
         .def_readonly("morphology",
-            &arborio::morphology_data::morphology,
+            &arborio::nml_morphology_data::morphology,
             "Morphology constructed from a signle NeuroML <morphology> element.")
         .def("segments",
-            [](const arborio::morphology_data& md) {return label_dict_proxy(md.segments);},
+            [](const arborio::nml_morphology_data& md) {return label_dict_proxy(md.segments);},
             "Label dictionary containing one region expression for each segment id.")
         .def("named_segments",
-             [](const arborio::morphology_data& md) {return label_dict_proxy(md.named_segments);},
+             [](const arborio::nml_morphology_data& md) {return label_dict_proxy(md.named_segments);},
             "Label dictionary containing one region expression for each name applied to one or more segments.")
         .def("groups",
-             [](const arborio::morphology_data& md) {return label_dict_proxy(md.groups);},
+             [](const arborio::nml_morphology_data& md) {return label_dict_proxy(md.groups);},
             "Label dictionary containing one region expression for each segmentGroup id.")
         .def_readonly("group_segments",
-            &arborio::morphology_data::group_segments,
+            &arborio::nml_morphology_data::group_segments,
             "Map from segmentGroup ids to their corresponding segment ids.");
 
     // arborio::neuroml
diff --git a/test/unit/test_nml_morphology.cpp b/test/unit/test_nml_morphology.cpp
index 6860aa80543c9dbf8087f9e3491861969a260489..ff69fe25549bbb3ae70e4433340dbe96fb9f8179 100644
--- a/test/unit/test_nml_morphology.cpp
+++ b/test/unit/test_nml_morphology.cpp
@@ -6,8 +6,8 @@
 #include <arbor/morph/place_pwlin.hpp>
 #include <arbor/morph/primitives.hpp>
 
-#include <arborio/arbornml.hpp>
-#include <arborio/with_xml.hpp>
+#include <arborio/neuroml.hpp>
+#include <arborio/xml.hpp>
 
 #include "../test/gtest.h"
 #include "morph_pred.hpp"
@@ -89,7 +89,7 @@ R"~(
     std::sort(c_ids.begin(), c_ids.end());
     EXPECT_EQ((svector{"c3", "c4"}), c_ids);
 
-    arborio::morphology_data mdata;
+    arborio::nml_morphology_data mdata;
 
     mdata = N.cell_morphology("c4").value();
     EXPECT_EQ("c4", mdata.cell_id);
@@ -176,7 +176,7 @@ R"~(
     arborio::neuroml N(doc);
 
     {
-        arborio::morphology_data m1 = N.morphology("m1").value();
+        arborio::nml_morphology_data m1 = N.morphology("m1").value();
         label_dict labels;
         labels.import(m1.segments, "seg:");
         mprovider P(m1.morphology, labels);
@@ -189,7 +189,7 @@ R"~(
     }
 
     {
-        arborio::morphology_data m2 = N.morphology("m2").value();
+        arborio::nml_morphology_data m2 = N.morphology("m2").value();
         label_dict labels;
         labels.import(m2.segments, "seg:");
         mprovider P(m2.morphology, labels);
@@ -217,7 +217,7 @@ R"~(
     }
 
     {
-        arborio::morphology_data m3 = N.morphology("m3").value();
+        arborio::nml_morphology_data m3 = N.morphology("m3").value();
         label_dict labels;
         labels.import(m3.segments, "seg:");
         mprovider P(m3.morphology, labels);
@@ -251,7 +251,7 @@ R"~(
     }
     {
         for (const char* m_name: {"m4", "m5"}) {
-            arborio::morphology_data m4_or_5 = N.morphology(m_name).value();
+            arborio::nml_morphology_data m4_or_5 = N.morphology(m_name).value();
             label_dict labels;
             labels.import(m4_or_5.segments, "seg:");
             mprovider P(m4_or_5.morphology, labels);
@@ -355,12 +355,12 @@ R"~(
 
     arborio::neuroml N(doc);
 
-    EXPECT_THROW(N.morphology("no-proximal").value(), arborio::bad_segment);
-    EXPECT_THROW(N.morphology("no-such-parent").value(), arborio::bad_segment);
-    EXPECT_THROW(N.morphology("cyclic-dependency").value(), arborio::cyclic_dependency);
-    EXPECT_THROW(N.morphology("duplicate-id").value(), arborio::bad_segment);
-    EXPECT_THROW(N.morphology("bad-segment-id").value(), arborio::bad_segment);
-    EXPECT_THROW(N.morphology("another-bad-segment-id").value(), arborio::bad_segment);
+    EXPECT_THROW(N.morphology("no-proximal").value(), arborio::nml_bad_segment);
+    EXPECT_THROW(N.morphology("no-such-parent").value(), arborio::nml_bad_segment);
+    EXPECT_THROW(N.morphology("cyclic-dependency").value(), arborio::nml_cyclic_dependency);
+    EXPECT_THROW(N.morphology("duplicate-id").value(), arborio::nml_bad_segment);
+    EXPECT_THROW(N.morphology("bad-segment-id").value(), arborio::nml_bad_segment);
+    EXPECT_THROW(N.morphology("another-bad-segment-id").value(), arborio::nml_bad_segment);
 }
 
 TEST(neuroml, simple_groups) {
@@ -444,7 +444,7 @@ R"~(
     using reg::named;
 
     {
-        arborio::morphology_data m1 = N.morphology("m1").value();
+        arborio::nml_morphology_data m1 = N.morphology("m1").value();
         label_dict labels;
         labels.import(m1.segments);
         labels.import(m1.groups);
@@ -455,7 +455,7 @@ R"~(
         EXPECT_TRUE(region_eq(P, named("group-c"), join(named("2"), named("1"))));
     }
     {
-        arborio::morphology_data m2 = N.morphology("m2").value();
+        arborio::nml_morphology_data m2 = N.morphology("m2").value();
         label_dict labels;
         labels.import(m2.segments);
         labels.import(m2.groups);
@@ -509,9 +509,9 @@ R"~(
 
     arborio::neuroml N(doc);
 
-    EXPECT_THROW(N.morphology("no-such-segment").value(), arborio::bad_segment_group);
-    EXPECT_THROW(N.morphology("no-such-group").value(), arborio::bad_segment_group);
-    EXPECT_THROW(N.morphology("cyclic-dependency").value(), arborio::cyclic_dependency);
+    EXPECT_THROW(N.morphology("no-such-segment").value(), arborio::nml_bad_segment_group);
+    EXPECT_THROW(N.morphology("no-such-group").value(), arborio::nml_bad_segment_group);
+    EXPECT_THROW(N.morphology("cyclic-dependency").value(), arborio::nml_cyclic_dependency);
 }
 
 
@@ -613,7 +613,7 @@ R"~(
 
     arborio::neuroml N(doc);
 
-    arborio::morphology_data m1 = N.morphology("m1").value();
+    arborio::nml_morphology_data m1 = N.morphology("m1").value();
     label_dict labels;
     labels.import(m1.segments);
     labels.import(m1.groups);